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::{OdosError, Result},
16 error_code::OdosErrorCode,
17};
18
19#[derive(Debug, Clone)]
49pub struct RetryConfig {
50 pub max_retries: u32,
52
53 pub initial_backoff_ms: u64,
55
56 pub retry_server_errors: bool,
58
59 pub retry_predicate: Option<fn(&OdosError) -> bool>,
64}
65
66impl Default for RetryConfig {
67 fn default() -> Self {
68 Self {
69 max_retries: 3,
70 initial_backoff_ms: 100,
71 retry_server_errors: true,
72 retry_predicate: None,
73 }
74 }
75}
76
77impl RetryConfig {
78 pub fn no_retries() -> Self {
83 Self {
84 max_retries: 0,
85 ..Default::default()
86 }
87 }
88
89 pub fn conservative() -> Self {
95 Self {
96 max_retries: 2,
97 retry_server_errors: false,
98 ..Default::default()
99 }
100 }
101}
102
103#[derive(Clone)]
160pub struct ClientConfig {
161 pub timeout: Duration,
169
170 pub connect_timeout: Duration,
177
178 pub retry_config: RetryConfig,
185
186 pub max_connections: usize,
193
194 pub pool_idle_timeout: Duration,
201
202 pub api_key: Option<ApiKey>,
209
210 pub endpoint: crate::Endpoint,
238}
239
240impl Default for ClientConfig {
241 fn default() -> Self {
242 Self {
243 timeout: Duration::from_secs(30),
244 connect_timeout: Duration::from_secs(10),
245 retry_config: RetryConfig::default(),
246 max_connections: 20,
247 pool_idle_timeout: Duration::from_secs(90),
248 api_key: None,
249 endpoint: crate::Endpoint::public_v2(),
250 }
251 }
252}
253
254impl std::fmt::Debug for ClientConfig {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("ClientConfig")
257 .field("timeout", &self.timeout)
258 .field("connect_timeout", &self.connect_timeout)
259 .field("retry_config", &self.retry_config)
260 .field("max_connections", &self.max_connections)
261 .field("pool_idle_timeout", &self.pool_idle_timeout)
262 .field("api_key", &self.api_key)
263 .field("endpoint", &self.endpoint)
264 .finish()
265 }
266}
267
268impl ClientConfig {
269 pub fn no_retries() -> Self {
273 Self {
274 retry_config: RetryConfig::no_retries(),
275 ..Default::default()
276 }
277 }
278
279 pub fn conservative() -> Self {
283 Self {
284 retry_config: RetryConfig::conservative(),
285 ..Default::default()
286 }
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct OdosHttpClient {
293 client: Client,
294 config: ClientConfig,
295}
296
297impl OdosHttpClient {
298 pub fn new() -> Result<Self> {
300 Self::with_config(ClientConfig::default())
301 }
302
303 pub fn with_config(config: ClientConfig) -> Result<Self> {
305 let client = Client::builder()
306 .timeout(config.timeout)
307 .connect_timeout(config.connect_timeout)
308 .pool_max_idle_per_host(config.max_connections)
309 .pool_idle_timeout(config.pool_idle_timeout)
310 .build()
311 .map_err(OdosError::Http)?;
312
313 Ok(Self { client, config })
314 }
315
316 #[instrument(skip(self, request_builder_fn), level = "debug")]
318 pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
319 where
320 F: Fn() -> RequestBuilder + Clone,
321 {
322 let initial_backoff_duration =
323 Duration::from_millis(self.config.retry_config.initial_backoff_ms);
324
325 let backoff = ExponentialBuilder::default()
328 .with_min_delay(initial_backoff_duration)
329 .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();
333 let mut attempt = 0;
334
335 loop {
336 attempt += 1;
337
338 let request = match request_builder_fn().build() {
339 Ok(req) => req,
340 Err(e) => return Err(OdosError::Http(e)),
341 };
342
343 let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
344 {
345 Ok(Ok(response)) if response.status().is_success() => {
346 return Ok(response);
347 }
348 Ok(Ok(response)) => {
349 let status = response.status();
350
351 if status == StatusCode::TOO_MANY_REQUESTS {
352 let retry_after = extract_retry_after(&response);
355 let parsed = parse_error_response(response).await;
356 return Err(OdosError::rate_limit_error_with_retry_after_and_trace(
357 parsed.message,
358 retry_after,
359 parsed.code,
360 parsed.trace_id,
361 ));
362 } else {
363 let parsed = parse_error_response(response).await;
365
366 let error = OdosError::api_error_with_code(
367 status,
368 parsed.message,
369 parsed.code,
370 parsed.trace_id,
371 );
372
373 if !self.should_retry(&error, attempt) {
374 return Err(error);
375 }
376
377 error
378 }
379 }
380 Ok(Err(e)) => {
381 let is_timeout = e.is_timeout();
382 let is_connect = e.is_connect();
383 let error = OdosError::Http(e);
384
385 if !self.should_retry(&error, attempt) {
386 return Err(error);
387 }
388 debug!(
389 error_type = "http_error",
390 attempt,
391 error = %error,
392 is_timeout,
393 is_connect,
394 "HTTP error occurred, will retry with backoff"
395 );
396 error
397 }
398 Err(_) => {
399 let error = OdosError::timeout_error("Request timed out");
400 debug!(
401 error_type = "timeout",
402 attempt,
403 timeout_secs = self.config.timeout.as_secs(),
404 "Request timed out, will retry with backoff"
405 );
406 error
407 }
408 };
409
410 if attempt >= self.config.retry_config.max_retries {
412 return Err(last_error);
413 }
414
415 if let Some(delay) = backoff_iter.next() {
417 tokio::time::sleep(delay).await;
418 } else {
419 return Err(last_error);
421 }
422 }
423 }
424
425 pub fn inner(&self) -> &Client {
427 &self.client
428 }
429
430 pub fn config(&self) -> &ClientConfig {
432 &self.config
433 }
434
435 fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
453 let retry_config = &self.config.retry_config;
454
455 if attempts >= retry_config.max_retries {
457 return false;
458 }
459
460 if let Some(predicate) = retry_config.retry_predicate {
462 return predicate(error);
463 }
464
465 match error {
467 OdosError::RateLimit { .. } => false,
469
470 OdosError::Api { status, .. } if status.is_client_error() => false,
472
473 OdosError::Api { status, .. } if status.is_server_error() => {
475 retry_config.retry_server_errors
476 }
477
478 OdosError::Http(err) => err.is_timeout() || err.is_connect() || err.is_request(),
480
481 OdosError::Timeout(_) => true,
483
484 _ => false,
486 }
487 }
488}
489
490fn extract_retry_after(response: &Response) -> Option<Duration> {
492 response
493 .headers()
494 .get("retry-after")
495 .and_then(|v| v.to_str().ok())
496 .and_then(|s| s.parse::<u64>().ok())
497 .map(Duration::from_secs)
498}
499
500#[derive(Debug, Clone)]
502struct ParsedErrorResponse {
503 message: String,
505 code: OdosErrorCode,
507 trace_id: Option<crate::error_code::TraceId>,
509}
510
511async fn parse_error_response(response: Response) -> ParsedErrorResponse {
517 let body_text = match response.text().await {
519 Ok(text) => text,
520 Err(e) => {
521 return ParsedErrorResponse {
522 message: format!("Failed to read response body: {}", e),
523 code: OdosErrorCode::Unknown(0),
524 trace_id: None,
525 }
526 }
527 };
528
529 match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
531 Ok(error_response) => {
532 let error_code = OdosErrorCode::from(error_response.error_code);
534 ParsedErrorResponse {
535 message: error_response.detail,
536 code: error_code,
537 trace_id: Some(error_response.trace_id),
538 }
539 }
540 Err(_) => {
541 ParsedErrorResponse {
543 message: body_text,
544 code: OdosErrorCode::Unknown(0),
545 trace_id: None,
546 }
547 }
548 }
549}
550
551impl Default for OdosHttpClient {
552 fn default() -> Self {
564 Self::new().expect("Failed to create default HTTP client")
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use std::sync::{Arc, Mutex};
572 use std::time::Duration;
573 use wiremock::{
574 matchers::{method, path},
575 Mock, MockServer, Request, ResponseTemplate,
576 };
577
578 fn create_retry_mock(
580 first_status: u16,
581 first_body: String,
582 success_after: usize,
583 ) -> impl Fn(&Request) -> ResponseTemplate {
584 let attempt_count = Arc::new(Mutex::new(0));
585 move |_req: &Request| {
586 let mut count = attempt_count.lock().unwrap();
587 *count += 1;
588
589 if *count < success_after {
590 ResponseTemplate::new(first_status).set_body_string(&first_body)
591 } else {
592 ResponseTemplate::new(200).set_body_string("Success")
593 }
594 }
595 }
596
597 fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
599 let config = ClientConfig {
600 timeout: Duration::from_millis(timeout_ms),
601 retry_config: RetryConfig {
602 max_retries,
603 initial_backoff_ms: 10,
604 ..Default::default()
605 },
606 ..Default::default()
607 };
608 OdosHttpClient::with_config(config).unwrap()
609 }
610
611 #[test]
612 fn test_client_config_default() {
613 let config = ClientConfig::default();
614 assert_eq!(config.timeout, Duration::from_secs(30));
615 assert_eq!(config.retry_config.max_retries, 3);
616 assert_eq!(config.max_connections, 20);
617 }
618
619 #[tokio::test]
620 async fn test_client_creation() {
621 let client = OdosHttpClient::new();
622 assert!(client.is_ok());
623 }
624
625 #[tokio::test]
626 async fn test_client_with_custom_config() {
627 let config = ClientConfig {
628 timeout: Duration::from_secs(60),
629 retry_config: RetryConfig {
630 max_retries: 5,
631 ..Default::default()
632 },
633 ..Default::default()
634 };
635 let client = OdosHttpClient::with_config(config.clone());
636 assert!(client.is_ok());
637
638 let client = client.unwrap();
639 assert_eq!(client.config().timeout, Duration::from_secs(60));
640 assert_eq!(client.config().retry_config.max_retries, 5);
641 }
642
643 #[tokio::test]
644 async fn test_rate_limit_with_retry_after() {
645 let mock_server = MockServer::start().await;
646
647 Mock::given(method("GET"))
649 .and(path("/test"))
650 .respond_with(
651 ResponseTemplate::new(429)
652 .set_body_string("Rate limit exceeded")
653 .insert_header("retry-after", "1"),
654 )
655 .expect(1) .mount(&mock_server)
657 .await;
658
659 let client = create_test_client(3, 30000);
660 let response = client
661 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
662 .await;
663
664 assert!(
666 response.is_err(),
667 "Rate limit should return error immediately"
668 );
669
670 if let Err(OdosError::RateLimit {
671 message,
672 retry_after,
673 ..
674 }) = response
675 {
676 assert!(message.contains("Rate limit"));
677 assert_eq!(retry_after, Some(Duration::from_secs(1)));
678 } else {
679 panic!("Expected RateLimit error, got: {response:?}");
680 }
681 }
682
683 #[tokio::test]
684 async fn test_rate_limit_without_retry_after() {
685 let mock_server = MockServer::start().await;
686
687 Mock::given(method("GET"))
689 .and(path("/test"))
690 .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
691 .expect(1) .mount(&mock_server)
693 .await;
694
695 let client = create_test_client(3, 30000);
696 let response = client
697 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
698 .await;
699
700 assert!(
702 response.is_err(),
703 "Rate limit should return error immediately"
704 );
705
706 if let Err(OdosError::RateLimit {
707 message,
708 retry_after,
709 ..
710 }) = response
711 {
712 assert!(message.contains("Rate limit"));
713 assert_eq!(retry_after, None);
714 } else {
715 panic!("Expected RateLimit error, got: {response:?}");
716 }
717 }
718
719 #[tokio::test]
720 async fn test_non_retryable_error() {
721 let mock_server = MockServer::start().await;
722
723 Mock::given(method("GET"))
725 .and(path("/test"))
726 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
727 .expect(1)
728 .mount(&mock_server)
729 .await;
730
731 let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
732
733 let response = client
734 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
735 .await;
736
737 assert!(response.is_err());
739 if let Err(e) = response {
740 assert!(!e.is_retryable());
741 }
742 }
743
744 #[tokio::test]
745 async fn test_retry_exhaustion_returns_last_error() {
746 let mock_server = MockServer::start().await;
747
748 Mock::given(method("GET"))
750 .and(path("/test"))
751 .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
752 .mount(&mock_server)
753 .await;
754
755 let client = create_test_client(2, 30000);
756
757 let response = client
758 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
759 .await;
760
761 assert!(response.is_err());
763 if let Err(e) = response {
764 assert!(
765 matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
766 );
767 }
768 }
769
770 #[tokio::test]
771 async fn test_timeout_error() {
772 let mock_server = MockServer::start().await;
773
774 Mock::given(method("GET"))
776 .and(path("/test"))
777 .respond_with(
778 ResponseTemplate::new(200)
779 .set_body_string("Success")
780 .set_delay(Duration::from_secs(5)),
781 )
782 .mount(&mock_server)
783 .await;
784
785 let client = create_test_client(2, 100);
786
787 let response = client
788 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
789 .await;
790
791 assert!(response.is_err());
793 if let Err(e) = response {
794 let is_timeout = matches!(e, OdosError::Timeout(_))
796 || matches!(e, OdosError::Http(ref err) if err.is_timeout());
797 assert!(is_timeout, "Expected timeout error, got: {e:?}");
798 }
799 }
800
801 #[tokio::test]
802 async fn test_invalid_request_builder_fails_immediately() {
803 let client = OdosHttpClient::default();
804
805 let bad_builder = || {
808 let mut builder = client.inner().get("http://localhost");
809 builder = builder.header("x".repeat(100000), "value");
811 builder
812 };
813
814 let result = client.execute_with_retry(bad_builder).await;
815
816 assert!(result.is_err());
818 if let Err(e) = result {
819 assert!(matches!(e, OdosError::Http(_)));
820 }
821 }
822
823 #[tokio::test]
824 async fn test_retryable_500_error() {
825 let mock_server = MockServer::start().await;
826
827 Mock::given(method("GET"))
828 .and(path("/test"))
829 .respond_with(create_retry_mock(
830 500,
831 "Internal server error".to_string(),
832 2,
833 ))
834 .mount(&mock_server)
835 .await;
836
837 let client = create_test_client(3, 30000);
838 let response = client
839 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
840 .await;
841
842 assert!(response.is_ok(), "500 error should be retried and succeed");
843 }
844
845 #[tokio::test]
846 async fn test_retryable_502_bad_gateway() {
847 let mock_server = MockServer::start().await;
848
849 Mock::given(method("GET"))
850 .and(path("/test"))
851 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
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(), "502 error should be retried and succeed");
861 }
862
863 #[tokio::test]
864 async fn test_retryable_503_service_unavailable() {
865 let mock_server = MockServer::start().await;
866
867 Mock::given(method("GET"))
868 .and(path("/test"))
869 .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
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(), "503 error should be retried and succeed");
879 }
880
881 #[tokio::test]
882 async fn test_retryable_504_gateway_timeout() {
883 let mock_server = MockServer::start().await;
884
885 Mock::given(method("GET"))
886 .and(path("/test"))
887 .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
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(), "504 error should be retried and succeed");
897 }
898
899 #[tokio::test]
900 async fn test_network_error_retryable() {
901 let client = create_test_client(2, 100);
903
904 let response = client
905 .execute_with_retry(|| client.inner().get("http://localhost:1"))
906 .await;
907
908 assert!(response.is_err());
910 if let Err(e) = response {
911 assert!(matches!(e, OdosError::Http(_)));
912 }
913 }
914
915 #[test]
916 fn test_accessor_methods() {
917 let config = ClientConfig {
918 timeout: Duration::from_secs(45),
919 retry_config: RetryConfig {
920 max_retries: 5,
921 ..Default::default()
922 },
923 ..Default::default()
924 };
925 let client = OdosHttpClient::with_config(config.clone()).unwrap();
926
927 assert_eq!(client.config().timeout, Duration::from_secs(45));
929 assert_eq!(client.config().retry_config.max_retries, 5);
930
931 let _inner: &reqwest::Client = client.inner();
933 }
934
935 #[test]
936 fn test_default_client() {
937 let client = OdosHttpClient::default();
938
939 assert_eq!(client.config().timeout, Duration::from_secs(30));
941 assert_eq!(client.config().retry_config.max_retries, 3);
942 }
943
944 #[test]
945 fn test_extract_retry_after_valid_numeric() {
946 let response = reqwest::Response::from(
947 http::Response::builder()
948 .status(429)
949 .header("retry-after", "30")
950 .body("")
951 .unwrap(),
952 );
953
954 let retry_after = extract_retry_after(&response);
955 assert_eq!(retry_after, Some(Duration::from_secs(30)));
956 }
957
958 #[test]
959 fn test_extract_retry_after_missing_header() {
960 let response =
961 reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
962
963 let retry_after = extract_retry_after(&response);
964 assert_eq!(retry_after, None);
965 }
966
967 #[test]
968 fn test_extract_retry_after_malformed_value() {
969 let response = reqwest::Response::from(
970 http::Response::builder()
971 .status(429)
972 .header("retry-after", "not-a-number")
973 .body("")
974 .unwrap(),
975 );
976
977 let retry_after = extract_retry_after(&response);
978 assert_eq!(retry_after, None);
979 }
980
981 #[test]
982 fn test_extract_retry_after_zero_value() {
983 let response = reqwest::Response::from(
984 http::Response::builder()
985 .status(429)
986 .header("retry-after", "0")
987 .body("")
988 .unwrap(),
989 );
990
991 let retry_after = extract_retry_after(&response);
992 assert_eq!(retry_after, Some(Duration::from_secs(0)));
993 }
994
995 #[tokio::test]
996 async fn test_rate_limit_with_retry_after_zero() {
997 let mock_server = MockServer::start().await;
998
999 Mock::given(method("GET"))
1001 .and(path("/test"))
1002 .respond_with(
1003 ResponseTemplate::new(429)
1004 .set_body_string("Rate limit exceeded")
1005 .insert_header("retry-after", "0"),
1006 )
1007 .expect(1) .mount(&mock_server)
1009 .await;
1010
1011 let client = create_test_client(3, 30000);
1012 let response = client
1013 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1014 .await;
1015
1016 assert!(
1018 response.is_err(),
1019 "Rate limit should return error immediately"
1020 );
1021
1022 if let Err(OdosError::RateLimit {
1023 message,
1024 retry_after,
1025 ..
1026 }) = response
1027 {
1028 assert!(message.contains("Rate limit"));
1029 assert_eq!(retry_after, Some(Duration::from_secs(0)));
1030 } else {
1031 panic!("Expected RateLimit error, got: {response:?}");
1032 }
1033 }
1034
1035 #[test]
1036 fn test_extract_retry_after_large_value() {
1037 let response = reqwest::Response::from(
1038 http::Response::builder()
1039 .status(429)
1040 .header("retry-after", "3600")
1041 .body("")
1042 .unwrap(),
1043 );
1044
1045 let retry_after = extract_retry_after(&response);
1046 assert_eq!(retry_after, Some(Duration::from_secs(3600)));
1047 }
1048
1049 #[test]
1050 fn test_extract_retry_after_invalid_utf8() {
1051 let response = reqwest::Response::from(
1052 http::Response::builder()
1053 .status(429)
1054 .header("retry-after", vec![0xff, 0xfe])
1055 .body("")
1056 .unwrap(),
1057 );
1058
1059 let retry_after = extract_retry_after(&response);
1060 assert_eq!(retry_after, None);
1061 }
1062
1063 #[test]
1064 fn test_client_config_debug_redacts_api_key() {
1065 use crate::ApiKey;
1066 use uuid::Uuid;
1067
1068 let uuid = Uuid::new_v4();
1069 let uuid_str = uuid.to_string();
1070 let api_key = ApiKey::new(uuid);
1071
1072 let config = ClientConfig {
1073 api_key: Some(api_key),
1074 ..Default::default()
1075 };
1076
1077 let debug_output = format!("{:?}", config);
1078
1079 assert!(debug_output.contains("[REDACTED]"));
1081
1082 assert!(
1084 !debug_output.contains(&uuid_str),
1085 "API key UUID should not appear in debug output, but found: {}",
1086 uuid_str
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn test_max_retries_zero() {
1092 let mock_server = MockServer::start().await;
1093
1094 Mock::given(method("GET"))
1096 .and(path("/test"))
1097 .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1098 .expect(1) .mount(&mock_server)
1100 .await;
1101
1102 let client = create_test_client(0, 30000); let response = client
1104 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1105 .await;
1106
1107 assert!(response.is_err());
1109 if let Err(e) = response {
1110 assert!(
1111 matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1112 );
1113 }
1114 }
1115
1116 #[tokio::test]
1117 async fn test_parse_structured_error_response() {
1118 use crate::error_code::OdosErrorCode;
1119
1120 let error_json = r#"{
1122 "detail": "Error getting quote, please try again",
1123 "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1124 "errorCode": 2999
1125 }"#;
1126
1127 let http_response = http::Response::builder()
1128 .status(500)
1129 .body(error_json)
1130 .unwrap();
1131 let response = reqwest::Response::from(http_response);
1132
1133 let parsed = parse_error_response(response).await;
1134
1135 assert_eq!(parsed.message, "Error getting quote, please try again");
1136 assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1137 assert!(parsed.trace_id.is_some());
1138 assert_eq!(
1139 parsed.trace_id.unwrap().to_string(),
1140 "10becdc8-a021-4491-8201-a17b657204e0"
1141 );
1142 }
1143
1144 #[tokio::test]
1145 async fn test_parse_unstructured_error_response() {
1146 let http_response = http::Response::builder()
1148 .status(500)
1149 .body("Internal server error")
1150 .unwrap();
1151 let response = reqwest::Response::from(http_response);
1152
1153 let parsed = parse_error_response(response).await;
1154
1155 assert_eq!(parsed.message, "Internal server error");
1156 assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1157 assert!(parsed.trace_id.is_none());
1158 }
1159
1160 #[tokio::test]
1161 async fn test_api_error_with_structured_response() {
1162 let mock_server = MockServer::start().await;
1163
1164 let error_json = r#"{
1165 "detail": "Invalid chain ID",
1166 "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1167 "errorCode": 4001
1168 }"#;
1169
1170 Mock::given(method("GET"))
1171 .and(path("/test"))
1172 .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1173 .expect(1)
1174 .mount(&mock_server)
1175 .await;
1176
1177 let client = create_test_client(0, 30000);
1178 let response = client
1179 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1180 .await;
1181
1182 assert!(response.is_err());
1183 if let Err(e) = response {
1184 assert!(matches!(e, OdosError::Api { .. }));
1186
1187 let error_code = e.error_code();
1189 assert!(error_code.is_some());
1190 assert!(error_code.unwrap().is_invalid_chain_id());
1191
1192 let trace_id = e.trace_id();
1194 assert!(trace_id.is_some());
1195 } else {
1196 panic!("Expected error, got success");
1197 }
1198 }
1199
1200 #[tokio::test]
1201 async fn test_client_config_failure() {
1202 let config = ClientConfig {
1205 max_connections: usize::MAX,
1206 ..Default::default()
1207 };
1208
1209 let result = OdosHttpClient::with_config(config);
1211
1212 match result {
1215 Ok(_) => {
1216 }
1218 Err(e) => {
1219 assert!(matches!(e, OdosError::Http(_)));
1221 }
1222 }
1223 }
1224
1225 #[tokio::test]
1226 async fn test_rate_limit_with_trace_id() {
1227 let mock_server = MockServer::start().await;
1228
1229 let error_json = r#"{
1230 "detail": "Rate limit exceeded",
1231 "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1232 "errorCode": 4299
1233 }"#;
1234
1235 Mock::given(method("GET"))
1236 .and(path("/test"))
1237 .respond_with(
1238 ResponseTemplate::new(429)
1239 .set_body_string(error_json)
1240 .insert_header("retry-after", "30"),
1241 )
1242 .expect(1)
1243 .mount(&mock_server)
1244 .await;
1245
1246 let client = create_test_client(0, 30000);
1247 let response = client
1248 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1249 .await;
1250
1251 assert!(response.is_err());
1252 if let Err(e) = response {
1253 assert!(e.is_rate_limit());
1255
1256 let trace_id = e.trace_id();
1258 assert!(trace_id.is_some());
1259 assert_eq!(
1260 trace_id.unwrap().to_string(),
1261 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1262 );
1263
1264 let error_msg = e.to_string();
1266 assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1267 assert!(error_msg.contains("[trace:"));
1268 } else {
1269 panic!("Expected error, got success");
1270 }
1271 }
1272}