1use std::time::Duration;
6
7use backon::{BackoffBuilder, ExponentialBuilder};
8use reqwest::{Client, RequestBuilder, Response, StatusCode};
9use tokio::time::timeout;
10use tracing::{debug, instrument};
11
12use crate::{
13 api::OdosApiErrorResponse,
14 api_key::ApiKey,
15 error::{ApiErrorBody, OdosError, Result},
16 error_code::OdosErrorCode,
17};
18
19#[derive(Debug, Clone, Copy, Default)]
35pub enum RetryPredicate {
36 #[default]
38 Default,
39
40 Replace(fn(&OdosError) -> bool),
44
45 DefaultExcept(fn(&OdosError) -> bool),
49}
50
51#[derive(Debug, Clone)]
87pub struct RetryConfig {
88 pub max_retries: u32,
90
91 pub initial_backoff_ms: u64,
93
94 pub retry_server_errors: bool,
96
97 pub retry_predicate: RetryPredicate,
100}
101
102impl Default for RetryConfig {
103 fn default() -> Self {
104 Self {
105 max_retries: 3,
106 initial_backoff_ms: 100,
107 retry_server_errors: true,
108 retry_predicate: RetryPredicate::Default,
109 }
110 }
111}
112
113impl RetryConfig {
114 pub fn no_retries() -> Self {
119 Self {
120 max_retries: 0,
121 ..Default::default()
122 }
123 }
124
125 pub fn conservative() -> Self {
131 Self {
132 max_retries: 2,
133 retry_server_errors: false,
134 ..Default::default()
135 }
136 }
137}
138
139#[derive(Clone)]
196pub struct ClientConfig {
197 pub timeout: Duration,
205
206 pub connect_timeout: Duration,
213
214 pub retry_config: RetryConfig,
221
222 pub max_connections: usize,
229
230 pub pool_idle_timeout: Duration,
237
238 pub api_key: Option<ApiKey>,
245
246 pub endpoint: crate::Endpoint,
274}
275
276impl Default for ClientConfig {
277 fn default() -> Self {
278 Self {
279 timeout: Duration::from_secs(30),
280 connect_timeout: Duration::from_secs(10),
281 retry_config: RetryConfig::default(),
282 max_connections: 20,
283 pool_idle_timeout: Duration::from_secs(90),
284 api_key: None,
285 endpoint: crate::Endpoint::public_v2(),
286 }
287 }
288}
289
290impl std::fmt::Debug for ClientConfig {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("ClientConfig")
293 .field("timeout", &self.timeout)
294 .field("connect_timeout", &self.connect_timeout)
295 .field("retry_config", &self.retry_config)
296 .field("max_connections", &self.max_connections)
297 .field("pool_idle_timeout", &self.pool_idle_timeout)
298 .field("api_key", &self.api_key)
299 .field("endpoint", &self.endpoint)
300 .finish()
301 }
302}
303
304impl ClientConfig {
305 pub fn no_retries() -> Self {
309 Self {
310 retry_config: RetryConfig::no_retries(),
311 ..Default::default()
312 }
313 }
314
315 pub fn conservative() -> Self {
319 Self {
320 retry_config: RetryConfig::conservative(),
321 ..Default::default()
322 }
323 }
324}
325
326#[derive(Debug, Clone)]
328pub struct OdosHttpClient {
329 client: Client,
330 config: ClientConfig,
331}
332
333impl OdosHttpClient {
334 pub fn new() -> Result<Self> {
336 Self::with_config(ClientConfig::default())
337 }
338
339 pub fn with_config(config: ClientConfig) -> Result<Self> {
341 let client = Client::builder()
342 .timeout(config.timeout)
343 .connect_timeout(config.connect_timeout)
344 .pool_max_idle_per_host(config.max_connections)
345 .pool_idle_timeout(config.pool_idle_timeout)
346 .build()
347 .map_err(OdosError::Http)?;
348
349 Ok(Self { client, config })
350 }
351
352 #[instrument(skip(self, request_builder_fn), level = "debug")]
354 pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
355 where
356 F: Fn() -> RequestBuilder + Clone,
357 {
358 let initial_backoff_duration =
359 Duration::from_millis(self.config.retry_config.initial_backoff_ms);
360
361 let backoff = ExponentialBuilder::default()
363 .with_min_delay(initial_backoff_duration)
364 .with_max_delay(Duration::from_secs(30))
365 .with_max_times(self.config.retry_config.max_retries as usize + 1);
366
367 let mut backoff_iter = backoff.build();
368 let mut attempt = 0;
369
370 loop {
371 attempt += 1;
372
373 let request = match request_builder_fn().build() {
374 Ok(req) => req,
375 Err(e) => return Err(OdosError::Http(e)),
376 };
377
378 let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
379 {
380 Ok(Ok(response)) if response.status().is_success() => {
381 return Ok(response);
382 }
383 Ok(Ok(response)) => {
384 let status = response.status();
385
386 if status == StatusCode::TOO_MANY_REQUESTS {
387 let retry_after = extract_retry_after(&response);
389 let body = parse_error_response(response).await;
390 return Err(OdosError::RateLimit { retry_after, body });
391 } else {
392 let body = parse_error_response(response).await;
393 let error = OdosError::Api { status, body };
394
395 if !self.should_retry(&error, attempt) {
396 return Err(error);
397 }
398
399 error
400 }
401 }
402 Ok(Err(e)) => {
403 let is_timeout = e.is_timeout();
404 let is_connect = e.is_connect();
405 let error = OdosError::Http(e);
406
407 if !self.should_retry(&error, attempt) {
408 return Err(error);
409 }
410 debug!(
411 error_type = "http_error",
412 attempt,
413 error = %error,
414 is_timeout,
415 is_connect,
416 "HTTP error occurred, will retry with backoff"
417 );
418 error
419 }
420 Err(_) => {
421 let error = OdosError::timeout_error("Request timed out");
422
423 if !self.should_retry(&error, attempt) {
424 return Err(error);
425 }
426 debug!(
427 error_type = "timeout",
428 attempt,
429 timeout_secs = self.config.timeout.as_secs(),
430 "Request timed out, will retry with backoff"
431 );
432 error
433 }
434 };
435
436 if attempt >= self.config.retry_config.max_retries {
437 return Err(last_error);
438 }
439
440 if let Some(delay) = backoff_iter.next() {
441 tokio::time::sleep(delay).await;
442 } else {
443 return Err(last_error);
444 }
445 }
446 }
447
448 pub fn inner(&self) -> &Client {
450 &self.client
451 }
452
453 pub fn config(&self) -> &ClientConfig {
455 &self.config
456 }
457
458 fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
489 let retry_config = &self.config.retry_config;
490
491 if attempts >= retry_config.max_retries {
492 return false;
493 }
494
495 match retry_config.retry_predicate {
496 RetryPredicate::Replace(p) => return p(error),
497 RetryPredicate::DefaultExcept(veto) if veto(error) => return false,
498 RetryPredicate::Default | RetryPredicate::DefaultExcept(_) => {}
499 }
500
501 if !retry_config.retry_server_errors && error.is_server_error() {
505 return false;
506 }
507
508 error.is_retryable()
509 }
510}
511
512fn extract_retry_after(response: &Response) -> Option<Duration> {
514 response
515 .headers()
516 .get("retry-after")
517 .and_then(|v| v.to_str().ok())
518 .and_then(|s| s.parse::<u64>().ok())
519 .map(Duration::from_secs)
520}
521
522pub(crate) async fn parse_error_response(response: Response) -> ApiErrorBody {
529 let body_text = match response.text().await {
530 Ok(text) => text,
531 Err(e) => {
532 return ApiErrorBody {
533 message: format!("Failed to read response body: {e}"),
534 code: OdosErrorCode::Unknown(0),
535 trace_id: None,
536 };
537 }
538 };
539
540 match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
541 Ok(error_response) => ApiErrorBody {
542 message: error_response.detail,
543 code: OdosErrorCode::from(error_response.error_code),
544 trace_id: error_response.trace_id,
545 },
546 Err(_) => ApiErrorBody {
547 message: body_text,
548 code: OdosErrorCode::Unknown(0),
549 trace_id: None,
550 },
551 }
552}
553
554impl Default for OdosHttpClient {
555 fn default() -> Self {
567 Self::new().expect("Failed to create default HTTP client")
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::error_code::OdosErrorCode;
575 use std::sync::{Arc, Mutex};
576 use std::time::Duration;
577 use wiremock::{
578 matchers::{method, path},
579 Mock, MockServer, Request, ResponseTemplate,
580 };
581
582 fn create_retry_mock(
584 first_status: u16,
585 first_body: String,
586 success_after: usize,
587 ) -> impl Fn(&Request) -> ResponseTemplate {
588 let attempt_count = Arc::new(Mutex::new(0));
589 move |_req: &Request| {
590 let mut count = attempt_count.lock().unwrap();
591 *count += 1;
592
593 if *count < success_after {
594 ResponseTemplate::new(first_status).set_body_string(&first_body)
595 } else {
596 ResponseTemplate::new(200).set_body_string("Success")
597 }
598 }
599 }
600
601 fn create_test_client_with_predicate(
604 max_retries: u32,
605 timeout_ms: u64,
606 retry_predicate: RetryPredicate,
607 ) -> OdosHttpClient {
608 let config = ClientConfig {
609 timeout: Duration::from_millis(timeout_ms),
610 retry_config: RetryConfig {
611 max_retries,
612 initial_backoff_ms: 10,
613 retry_predicate,
614 ..Default::default()
615 },
616 ..Default::default()
617 };
618 OdosHttpClient::with_config(config).unwrap()
619 }
620
621 fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
623 create_test_client_with_predicate(max_retries, timeout_ms, RetryPredicate::Default)
624 }
625
626 #[test]
627 fn test_client_config_default() {
628 let config = ClientConfig::default();
629 assert_eq!(config.timeout, Duration::from_secs(30));
630 assert_eq!(config.retry_config.max_retries, 3);
631 assert_eq!(config.max_connections, 20);
632 }
633
634 #[tokio::test]
635 async fn test_client_creation() {
636 let client = OdosHttpClient::new();
637 assert!(client.is_ok());
638 }
639
640 #[tokio::test]
641 async fn test_client_with_custom_config() {
642 let config = ClientConfig {
643 timeout: Duration::from_secs(60),
644 retry_config: RetryConfig {
645 max_retries: 5,
646 ..Default::default()
647 },
648 ..Default::default()
649 };
650 let client = OdosHttpClient::with_config(config.clone());
651 assert!(client.is_ok());
652
653 let client = client.unwrap();
654 assert_eq!(client.config().timeout, Duration::from_secs(60));
655 assert_eq!(client.config().retry_config.max_retries, 5);
656 }
657
658 #[tokio::test]
659 async fn test_rate_limit_with_retry_after() {
660 let mock_server = MockServer::start().await;
661
662 Mock::given(method("GET"))
664 .and(path("/test"))
665 .respond_with(
666 ResponseTemplate::new(429)
667 .set_body_string("Rate limit exceeded")
668 .insert_header("retry-after", "1"),
669 )
670 .expect(1) .mount(&mock_server)
672 .await;
673
674 let client = create_test_client(3, 30000);
675 let response = client
676 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
677 .await;
678
679 assert!(
681 response.is_err(),
682 "Rate limit should return error immediately"
683 );
684
685 if let Err(OdosError::RateLimit {
686 retry_after, body, ..
687 }) = response
688 {
689 assert!(body.message.contains("Rate limit"));
690 assert_eq!(retry_after, Some(Duration::from_secs(1)));
691 } else {
692 panic!("Expected RateLimit error, got: {response:?}");
693 }
694 }
695
696 #[tokio::test]
697 async fn test_rate_limit_without_retry_after() {
698 let mock_server = MockServer::start().await;
699
700 Mock::given(method("GET"))
702 .and(path("/test"))
703 .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
704 .expect(1) .mount(&mock_server)
706 .await;
707
708 let client = create_test_client(3, 30000);
709 let response = client
710 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
711 .await;
712
713 assert!(
715 response.is_err(),
716 "Rate limit should return error immediately"
717 );
718
719 if let Err(OdosError::RateLimit {
720 retry_after, body, ..
721 }) = response
722 {
723 assert!(body.message.contains("Rate limit"));
724 assert_eq!(retry_after, None);
725 } else {
726 panic!("Expected RateLimit error, got: {response:?}");
727 }
728 }
729
730 #[tokio::test]
731 async fn test_non_retryable_error() {
732 let mock_server = MockServer::start().await;
733
734 Mock::given(method("GET"))
736 .and(path("/test"))
737 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
738 .expect(1)
739 .mount(&mock_server)
740 .await;
741
742 let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
743
744 let response = client
745 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
746 .await;
747
748 assert!(response.is_err());
750 if let Err(e) = response {
751 assert!(!e.is_retryable());
752 }
753 }
754
755 #[tokio::test]
756 async fn test_retry_exhaustion_returns_last_error() {
757 let mock_server = MockServer::start().await;
758
759 Mock::given(method("GET"))
761 .and(path("/test"))
762 .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
763 .mount(&mock_server)
764 .await;
765
766 let client = create_test_client(2, 30000);
767
768 let response = client
769 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
770 .await;
771
772 assert!(response.is_err());
774 if let Err(e) = response {
775 assert!(
776 matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
777 );
778 }
779 }
780
781 #[tokio::test]
782 async fn test_timeout_error() {
783 let mock_server = MockServer::start().await;
784
785 Mock::given(method("GET"))
787 .and(path("/test"))
788 .respond_with(
789 ResponseTemplate::new(200)
790 .set_body_string("Success")
791 .set_delay(Duration::from_secs(5)),
792 )
793 .mount(&mock_server)
794 .await;
795
796 let client = create_test_client(2, 100);
797
798 let response = client
799 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
800 .await;
801
802 assert!(response.is_err());
804 if let Err(e) = response {
805 let is_timeout = matches!(e, OdosError::Timeout(_))
807 || matches!(e, OdosError::Http(ref err) if err.is_timeout());
808 assert!(is_timeout, "Expected timeout error, got: {e:?}");
809 }
810 }
811
812 #[tokio::test]
813 async fn test_invalid_request_builder_fails_immediately() {
814 let client = OdosHttpClient::default();
815
816 let bad_builder = || {
819 let mut builder = client.inner().get("http://localhost");
820 builder = builder.header("x".repeat(100000), "value");
822 builder
823 };
824
825 let result = client.execute_with_retry(bad_builder).await;
826
827 assert!(result.is_err());
829 if let Err(e) = result {
830 assert!(matches!(e, OdosError::Http(_)));
831 }
832 }
833
834 #[tokio::test]
835 async fn test_retryable_500_error() {
836 let mock_server = MockServer::start().await;
837
838 Mock::given(method("GET"))
839 .and(path("/test"))
840 .respond_with(create_retry_mock(
841 500,
842 "Internal server error".to_string(),
843 2,
844 ))
845 .mount(&mock_server)
846 .await;
847
848 let client = create_test_client(3, 30000);
849 let response = client
850 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
851 .await;
852
853 assert!(response.is_ok(), "500 error should be retried and succeed");
854 }
855
856 #[tokio::test]
857 async fn test_retryable_502_bad_gateway() {
858 let mock_server = MockServer::start().await;
859
860 Mock::given(method("GET"))
861 .and(path("/test"))
862 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
863 .mount(&mock_server)
864 .await;
865
866 let client = create_test_client(3, 30000);
867 let response = client
868 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
869 .await;
870
871 assert!(response.is_ok(), "502 error should be retried and succeed");
872 }
873
874 #[tokio::test]
875 async fn test_retryable_503_service_unavailable() {
876 let mock_server = MockServer::start().await;
877
878 Mock::given(method("GET"))
879 .and(path("/test"))
880 .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
881 .mount(&mock_server)
882 .await;
883
884 let client = create_test_client(3, 30000);
885 let response = client
886 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
887 .await;
888
889 assert!(response.is_ok(), "503 error should be retried and succeed");
890 }
891
892 #[tokio::test]
893 async fn test_retryable_504_gateway_timeout() {
894 let mock_server = MockServer::start().await;
895
896 Mock::given(method("GET"))
897 .and(path("/test"))
898 .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
899 .mount(&mock_server)
900 .await;
901
902 let client = create_test_client(3, 30000);
903 let response = client
904 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
905 .await;
906
907 assert!(response.is_ok(), "504 error should be retried and succeed");
908 }
909
910 #[tokio::test]
911 async fn test_algo_internal_2999_not_retried() {
912 let mock_server = MockServer::start().await;
913
914 let error_json = r#"{
915 "detail": "Error getting quote, please try again",
916 "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
917 "errorCode": 2999
918 }"#;
919
920 Mock::given(method("GET"))
921 .and(path("/test"))
922 .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
923 .expect(1)
924 .mount(&mock_server)
925 .await;
926
927 let client = create_test_client(3, 30000);
928 let response = client
929 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
930 .await;
931
932 assert!(response.is_err());
933 match response {
934 Err(OdosError::Api { status, body }) => {
935 assert_eq!(body.code, OdosErrorCode::AlgoInternal);
936 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
937 }
938 other => panic!("Expected OdosError::Api with AlgoInternal, got: {other:?}"),
939 }
940 }
941
942 #[tokio::test]
943 async fn test_typed_retryable_code_still_retried() {
944 let mock_server = MockServer::start().await;
945
946 let error_json = r#"{
948 "detail": "Algorithm timeout",
949 "traceId": "20becdc8-a021-4491-8201-a17b657204e0",
950 "errorCode": 2998
951 }"#;
952
953 Mock::given(method("GET"))
954 .and(path("/test"))
955 .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
956 .expect(3) .mount(&mock_server)
958 .await;
959
960 let client = create_test_client(3, 30000);
961 let response = client
962 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
963 .await;
964
965 assert!(response.is_err());
966 match response {
967 Err(OdosError::Api { body, .. }) => {
968 assert_eq!(body.code, OdosErrorCode::AlgoTimeout);
969 }
970 other => panic!("Expected OdosError::Api with AlgoTimeout, got: {other:?}"),
971 }
972 }
973
974 #[tokio::test]
975 async fn test_retry_predicate_default_matches_built_in_tree() {
976 let mock_server = MockServer::start().await;
979
980 Mock::given(method("GET"))
981 .and(path("/test"))
982 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
983 .mount(&mock_server)
984 .await;
985
986 let client = create_test_client_with_predicate(3, 30000, RetryPredicate::Default);
987 let response = client
988 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
989 .await;
990
991 assert!(
992 response.is_ok(),
993 "502 should still be retried under RetryPredicate::Default"
994 );
995 }
996
997 #[tokio::test]
998 async fn test_retry_predicate_replace_can_disable_retries() {
999 let mock_server = MockServer::start().await;
1001
1002 Mock::given(method("GET"))
1003 .and(path("/test"))
1004 .respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
1005 .expect(1) .mount(&mock_server)
1007 .await;
1008
1009 let client =
1010 create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| false));
1011 let response = client
1012 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1013 .await;
1014
1015 assert!(response.is_err());
1016 }
1017
1018 #[tokio::test]
1019 async fn test_retry_predicate_applies_to_timeouts() {
1020 let mock_server = MockServer::start().await;
1023
1024 Mock::given(method("GET"))
1025 .and(path("/test"))
1026 .respond_with(
1027 ResponseTemplate::new(200)
1028 .set_body_string("Success")
1029 .set_delay(Duration::from_secs(5)),
1030 )
1031 .expect(1) .mount(&mock_server)
1033 .await;
1034
1035 let client =
1036 create_test_client_with_predicate(3, 100, RetryPredicate::Replace(|_err| false));
1037 let response = client
1038 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1039 .await;
1040
1041 assert!(response.is_err());
1042 }
1043
1044 #[tokio::test]
1045 async fn test_retry_predicate_replace_can_force_retries() {
1046 let mock_server = MockServer::start().await;
1049
1050 Mock::given(method("GET"))
1051 .and(path("/test"))
1052 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
1053 .expect(3) .mount(&mock_server)
1055 .await;
1056
1057 let client =
1058 create_test_client_with_predicate(3, 30000, RetryPredicate::Replace(|_err| true));
1059 let response = client
1060 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1061 .await;
1062
1063 assert!(response.is_err());
1064 }
1065
1066 #[tokio::test]
1067 async fn test_retry_predicate_replace_bypasses_retry_server_errors_gate() {
1068 let mock_server = MockServer::start().await;
1072
1073 Mock::given(method("GET"))
1074 .and(path("/test"))
1075 .respond_with(ResponseTemplate::new(500).set_body_string("Internal error"))
1076 .expect(3)
1077 .mount(&mock_server)
1078 .await;
1079
1080 let config = ClientConfig {
1081 timeout: Duration::from_millis(30000),
1082 retry_config: RetryConfig {
1083 max_retries: 3,
1084 initial_backoff_ms: 10,
1085 retry_server_errors: false,
1086 retry_predicate: RetryPredicate::Replace(|_err| true),
1087 },
1088 ..Default::default()
1089 };
1090 let client = OdosHttpClient::with_config(config).unwrap();
1091 let response = client
1092 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1093 .await;
1094
1095 match response {
1096 Err(OdosError::Api { status, .. }) => {
1097 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
1098 }
1099 other => panic!("Expected OdosError::Api with 500 status, got: {other:?}"),
1100 }
1101 }
1102
1103 #[tokio::test]
1104 async fn test_retry_predicate_default_except_vetoes_retryable_code() {
1105 let mock_server = MockServer::start().await;
1109
1110 let error_json = r#"{
1111 "detail": "Pricing service internal error",
1112 "traceId": "30becdc8-a021-4491-8201-a17b657204e0",
1113 "errorCode": 3130
1114 }"#;
1115
1116 Mock::given(method("GET"))
1117 .and(path("/test"))
1118 .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
1119 .expect(1) .mount(&mock_server)
1121 .await;
1122
1123 let client = create_test_client_with_predicate(
1124 3,
1125 30000,
1126 RetryPredicate::DefaultExcept(|err| {
1127 err.error_code() == Some(&OdosErrorCode::PricingInternal)
1128 }),
1129 );
1130 let response = client
1131 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1132 .await;
1133
1134 assert!(response.is_err());
1135 match response {
1136 Err(OdosError::Api { body, .. }) => {
1137 assert_eq!(body.code, OdosErrorCode::PricingInternal);
1138 }
1139 other => panic!("Expected OdosError::Api with PricingInternal, got: {other:?}"),
1140 }
1141 }
1142
1143 #[tokio::test]
1144 async fn test_retry_predicate_default_except_falls_through_when_not_matched() {
1145 let mock_server = MockServer::start().await;
1148
1149 Mock::given(method("GET"))
1150 .and(path("/test"))
1151 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
1152 .mount(&mock_server)
1153 .await;
1154
1155 let client = create_test_client_with_predicate(
1156 3,
1157 30000,
1158 RetryPredicate::DefaultExcept(|_err| false),
1159 );
1160 let response = client
1161 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1162 .await;
1163
1164 assert!(
1165 response.is_ok(),
1166 "502 should still be retried when DefaultExcept veto returns false"
1167 );
1168 }
1169
1170 #[tokio::test]
1171 async fn test_network_error_retryable() {
1172 let client = create_test_client(2, 100);
1174
1175 let response = client
1176 .execute_with_retry(|| client.inner().get("http://localhost:1"))
1177 .await;
1178
1179 assert!(response.is_err());
1181 if let Err(e) = response {
1182 assert!(matches!(e, OdosError::Http(_)));
1183 }
1184 }
1185
1186 #[test]
1187 fn test_accessor_methods() {
1188 let config = ClientConfig {
1189 timeout: Duration::from_secs(45),
1190 retry_config: RetryConfig {
1191 max_retries: 5,
1192 ..Default::default()
1193 },
1194 ..Default::default()
1195 };
1196 let client = OdosHttpClient::with_config(config.clone()).unwrap();
1197
1198 assert_eq!(client.config().timeout, Duration::from_secs(45));
1200 assert_eq!(client.config().retry_config.max_retries, 5);
1201
1202 let _inner: &reqwest::Client = client.inner();
1204 }
1205
1206 #[test]
1207 fn test_default_client() {
1208 let client = OdosHttpClient::default();
1209
1210 assert_eq!(client.config().timeout, Duration::from_secs(30));
1212 assert_eq!(client.config().retry_config.max_retries, 3);
1213 }
1214
1215 #[test]
1216 fn test_extract_retry_after_valid_numeric() {
1217 let response = reqwest::Response::from(
1218 http::Response::builder()
1219 .status(429)
1220 .header("retry-after", "30")
1221 .body("")
1222 .unwrap(),
1223 );
1224
1225 let retry_after = extract_retry_after(&response);
1226 assert_eq!(retry_after, Some(Duration::from_secs(30)));
1227 }
1228
1229 #[test]
1230 fn test_extract_retry_after_missing_header() {
1231 let response =
1232 reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
1233
1234 let retry_after = extract_retry_after(&response);
1235 assert_eq!(retry_after, None);
1236 }
1237
1238 #[test]
1239 fn test_extract_retry_after_malformed_value() {
1240 let response = reqwest::Response::from(
1241 http::Response::builder()
1242 .status(429)
1243 .header("retry-after", "not-a-number")
1244 .body("")
1245 .unwrap(),
1246 );
1247
1248 let retry_after = extract_retry_after(&response);
1249 assert_eq!(retry_after, None);
1250 }
1251
1252 #[test]
1253 fn test_extract_retry_after_zero_value() {
1254 let response = reqwest::Response::from(
1255 http::Response::builder()
1256 .status(429)
1257 .header("retry-after", "0")
1258 .body("")
1259 .unwrap(),
1260 );
1261
1262 let retry_after = extract_retry_after(&response);
1263 assert_eq!(retry_after, Some(Duration::from_secs(0)));
1264 }
1265
1266 #[tokio::test]
1267 async fn test_rate_limit_with_retry_after_zero() {
1268 let mock_server = MockServer::start().await;
1269
1270 Mock::given(method("GET"))
1272 .and(path("/test"))
1273 .respond_with(
1274 ResponseTemplate::new(429)
1275 .set_body_string("Rate limit exceeded")
1276 .insert_header("retry-after", "0"),
1277 )
1278 .expect(1) .mount(&mock_server)
1280 .await;
1281
1282 let client = create_test_client(3, 30000);
1283 let response = client
1284 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1285 .await;
1286
1287 assert!(
1289 response.is_err(),
1290 "Rate limit should return error immediately"
1291 );
1292
1293 if let Err(OdosError::RateLimit {
1294 retry_after, body, ..
1295 }) = response
1296 {
1297 assert!(body.message.contains("Rate limit"));
1298 assert_eq!(retry_after, Some(Duration::from_secs(0)));
1299 } else {
1300 panic!("Expected RateLimit error, got: {response:?}");
1301 }
1302 }
1303
1304 #[test]
1305 fn test_extract_retry_after_large_value() {
1306 let response = reqwest::Response::from(
1307 http::Response::builder()
1308 .status(429)
1309 .header("retry-after", "3600")
1310 .body("")
1311 .unwrap(),
1312 );
1313
1314 let retry_after = extract_retry_after(&response);
1315 assert_eq!(retry_after, Some(Duration::from_secs(3600)));
1316 }
1317
1318 #[test]
1319 fn test_extract_retry_after_invalid_utf8() {
1320 let response = reqwest::Response::from(
1321 http::Response::builder()
1322 .status(429)
1323 .header("retry-after", vec![0xff, 0xfe])
1324 .body("")
1325 .unwrap(),
1326 );
1327
1328 let retry_after = extract_retry_after(&response);
1329 assert_eq!(retry_after, None);
1330 }
1331
1332 #[test]
1333 fn test_client_config_debug_redacts_api_key() {
1334 use crate::ApiKey;
1335 use uuid::Uuid;
1336
1337 let uuid = Uuid::new_v4();
1338 let uuid_str = uuid.to_string();
1339 let api_key = ApiKey::new(uuid);
1340
1341 let config = ClientConfig {
1342 api_key: Some(api_key),
1343 ..Default::default()
1344 };
1345
1346 let debug_output = format!("{:?}", config);
1347
1348 assert!(debug_output.contains("[REDACTED]"));
1350
1351 assert!(
1353 !debug_output.contains(&uuid_str),
1354 "API key UUID should not appear in debug output, but found: {}",
1355 uuid_str
1356 );
1357 }
1358
1359 #[tokio::test]
1360 async fn test_max_retries_zero() {
1361 let mock_server = MockServer::start().await;
1362
1363 Mock::given(method("GET"))
1365 .and(path("/test"))
1366 .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1367 .expect(1) .mount(&mock_server)
1369 .await;
1370
1371 let client = create_test_client(0, 30000); let response = client
1373 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1374 .await;
1375
1376 assert!(response.is_err());
1378 if let Err(e) = response {
1379 assert!(
1380 matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1381 );
1382 }
1383 }
1384
1385 #[tokio::test]
1386 async fn test_parse_structured_error_response() {
1387 let error_json = r#"{
1389 "detail": "Error getting quote, please try again",
1390 "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1391 "errorCode": 2999
1392 }"#;
1393
1394 let http_response = http::Response::builder()
1395 .status(500)
1396 .body(error_json)
1397 .unwrap();
1398 let response = reqwest::Response::from(http_response);
1399
1400 let parsed = parse_error_response(response).await;
1401
1402 assert_eq!(parsed.message, "Error getting quote, please try again");
1403 assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1404 assert!(parsed.trace_id.is_some());
1405 assert_eq!(
1406 parsed.trace_id.unwrap().to_string(),
1407 "10becdc8-a021-4491-8201-a17b657204e0"
1408 );
1409 }
1410
1411 #[tokio::test]
1412 async fn test_parse_unstructured_error_response() {
1413 let http_response = http::Response::builder()
1415 .status(500)
1416 .body("Internal server error")
1417 .unwrap();
1418 let response = reqwest::Response::from(http_response);
1419
1420 let parsed = parse_error_response(response).await;
1421
1422 assert_eq!(parsed.message, "Internal server error");
1423 assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1424 assert!(parsed.trace_id.is_none());
1425 }
1426
1427 #[tokio::test]
1428 async fn test_api_error_with_structured_response() {
1429 let mock_server = MockServer::start().await;
1430
1431 let error_json = r#"{
1432 "detail": "Invalid chain ID",
1433 "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1434 "errorCode": 4001
1435 }"#;
1436
1437 Mock::given(method("GET"))
1438 .and(path("/test"))
1439 .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1440 .expect(1)
1441 .mount(&mock_server)
1442 .await;
1443
1444 let client = create_test_client(0, 30000);
1445 let response = client
1446 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1447 .await;
1448
1449 assert!(response.is_err());
1450 if let Err(e) = response {
1451 assert!(matches!(e, OdosError::Api { .. }));
1453
1454 let error_code = e.error_code();
1456 assert!(error_code.is_some());
1457 assert!(error_code.unwrap().is_invalid_chain_id());
1458
1459 let trace_id = e.trace_id();
1461 assert!(trace_id.is_some());
1462 } else {
1463 panic!("Expected error, got success");
1464 }
1465 }
1466
1467 #[tokio::test]
1468 async fn test_api_error_with_null_trace_id_preserves_error_code() {
1469 let mock_server = MockServer::start().await;
1470
1471 let error_json = r#"{
1472 "detail": "Error getting quote, please try again",
1473 "traceId": null,
1474 "errorCode": 2999
1475 }"#;
1476
1477 Mock::given(method("GET"))
1478 .and(path("/test"))
1479 .respond_with(ResponseTemplate::new(500).set_body_string(error_json))
1480 .expect(1)
1481 .mount(&mock_server)
1482 .await;
1483
1484 let client = create_test_client(0, 30000);
1485 let response = client
1486 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1487 .await;
1488
1489 let err = response.expect_err("expected API error");
1490 assert!(matches!(err, OdosError::Api { .. }));
1491 assert_eq!(err.error_code(), Some(&OdosErrorCode::AlgoInternal));
1492 assert_eq!(err.trace_id(), None);
1493 }
1494
1495 #[tokio::test]
1496 async fn test_client_config_failure() {
1497 let config = ClientConfig {
1500 max_connections: usize::MAX,
1501 ..Default::default()
1502 };
1503
1504 let result = OdosHttpClient::with_config(config);
1506
1507 match result {
1510 Ok(_) => {
1511 }
1513 Err(e) => {
1514 assert!(matches!(e, OdosError::Http(_)));
1516 }
1517 }
1518 }
1519
1520 #[tokio::test]
1521 async fn test_rate_limit_with_trace_id() {
1522 let mock_server = MockServer::start().await;
1523
1524 let error_json = r#"{
1525 "detail": "Rate limit exceeded",
1526 "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1527 "errorCode": 4299
1528 }"#;
1529
1530 Mock::given(method("GET"))
1531 .and(path("/test"))
1532 .respond_with(
1533 ResponseTemplate::new(429)
1534 .set_body_string(error_json)
1535 .insert_header("retry-after", "30"),
1536 )
1537 .expect(1)
1538 .mount(&mock_server)
1539 .await;
1540
1541 let client = create_test_client(0, 30000);
1542 let response = client
1543 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1544 .await;
1545
1546 assert!(response.is_err());
1547 if let Err(e) = response {
1548 assert!(e.is_rate_limit());
1550
1551 let trace_id = e.trace_id();
1553 assert!(trace_id.is_some());
1554 assert_eq!(
1555 trace_id.unwrap().to_string(),
1556 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1557 );
1558
1559 let error_msg = e.to_string();
1561 assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1562 assert!(error_msg.contains("[trace:"));
1563 } else {
1564 panic!("Expected error, got success");
1565 }
1566 }
1567}