1use std::time::Duration;
2
3use backoff::{backoff::Backoff, ExponentialBackoff};
4use reqwest::{Client, RequestBuilder, Response, StatusCode, Url};
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)]
103pub struct ClientConfig {
104 pub timeout: Duration,
106 pub connect_timeout: Duration,
108 pub retry_config: RetryConfig,
110 pub max_connections: usize,
112 pub pool_idle_timeout: Duration,
114 pub api_key: Option<ApiKey>,
116 pub quote_url: Url,
118 pub assemble_url: Url,
120}
121
122impl Default for ClientConfig {
123 fn default() -> Self {
124 Self {
125 timeout: Duration::from_secs(30),
126 connect_timeout: Duration::from_secs(10),
127 retry_config: RetryConfig::default(),
128 max_connections: 20,
129 pool_idle_timeout: Duration::from_secs(90),
130 api_key: None,
131 quote_url: Url::parse("https://api.odos.xyz/sor/quote/v2")
132 .expect("Invalid default quote URL"),
133 assemble_url: Url::parse("https://api.odos.xyz/sor/assemble")
134 .expect("Invalid default assemble URL"),
135 }
136 }
137}
138
139impl std::fmt::Debug for ClientConfig {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.debug_struct("ClientConfig")
142 .field("timeout", &self.timeout)
143 .field("connect_timeout", &self.connect_timeout)
144 .field("retry_config", &self.retry_config)
145 .field("max_connections", &self.max_connections)
146 .field("pool_idle_timeout", &self.pool_idle_timeout)
147 .field("api_key", &self.api_key)
148 .field("quote_url", &self.quote_url)
149 .field("assemble_url", &self.assemble_url)
150 .finish()
151 }
152}
153
154impl ClientConfig {
155 pub fn no_retries() -> Self {
159 Self {
160 retry_config: RetryConfig::no_retries(),
161 ..Default::default()
162 }
163 }
164
165 pub fn conservative() -> Self {
169 Self {
170 retry_config: RetryConfig::conservative(),
171 ..Default::default()
172 }
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct OdosHttpClient {
179 client: Client,
180 config: ClientConfig,
181}
182
183impl OdosHttpClient {
184 pub fn new() -> Result<Self> {
186 Self::with_config(ClientConfig::default())
187 }
188
189 pub fn with_config(config: ClientConfig) -> Result<Self> {
191 let client = Client::builder()
192 .timeout(config.timeout)
193 .connect_timeout(config.connect_timeout)
194 .pool_max_idle_per_host(config.max_connections)
195 .pool_idle_timeout(config.pool_idle_timeout)
196 .build()
197 .map_err(OdosError::Http)?;
198
199 Ok(Self { client, config })
200 }
201
202 #[instrument(skip(self, request_builder_fn), level = "debug")]
204 pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
205 where
206 F: Fn() -> RequestBuilder + Clone,
207 {
208 let initial_backoff_duration =
209 Duration::from_millis(self.config.retry_config.initial_backoff_ms);
210 let mut backoff = ExponentialBackoff {
211 initial_interval: initial_backoff_duration,
212 max_interval: Duration::from_secs(30), max_elapsed_time: Some(self.config.timeout),
214 ..Default::default()
215 };
216
217 let mut attempt = 0;
218
219 loop {
220 attempt += 1;
221
222 let request = match request_builder_fn().build() {
223 Ok(req) => req,
224 Err(e) => return Err(OdosError::Http(e)),
225 };
226
227 let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
228 {
229 Ok(Ok(response)) if response.status().is_success() => {
230 return Ok(response);
231 }
232 Ok(Ok(response)) => {
233 let status = response.status();
234
235 if status == StatusCode::TOO_MANY_REQUESTS {
236 let retry_after = extract_retry_after(&response);
237
238 let parsed = parse_error_response(response).await;
240
241 let error = OdosError::rate_limit_error_with_retry_after_and_trace(
242 parsed.message,
243 retry_after,
244 parsed.code,
245 parsed.trace_id,
246 );
247
248 if !self.should_retry(&error, attempt) {
250 return Err(error);
251 }
252
253 if let Some(delay) = retry_after {
254 if !delay.is_zero() {
256 debug!(
257 error_type = "rate_limit",
258 attempt,
259 retry_after_secs = delay.as_secs(),
260 action = "sleeping",
261 "Rate limit hit, sleeping before retry"
262 );
263 tokio::time::sleep(delay).await;
264 continue;
265 }
266 }
267 error
268 } else {
269 let parsed = parse_error_response(response).await;
271
272 let error = OdosError::api_error_with_code(
273 status,
274 parsed.message,
275 parsed.code,
276 parsed.trace_id,
277 );
278
279 if !self.should_retry(&error, attempt) {
280 return Err(error);
281 }
282
283 error
284 }
285 }
286 Ok(Err(e)) => {
287 let is_timeout = e.is_timeout();
288 let is_connect = e.is_connect();
289 let error = OdosError::Http(e);
290
291 if !self.should_retry(&error, attempt) {
292 return Err(error);
293 }
294 debug!(
295 error_type = "http_error",
296 attempt,
297 error = %error,
298 is_timeout,
299 is_connect,
300 "HTTP error occurred, will retry with backoff"
301 );
302 error
303 }
304 Err(_) => {
305 let error = OdosError::timeout_error("Request timed out");
306 debug!(
307 error_type = "timeout",
308 attempt,
309 timeout_secs = self.config.timeout.as_secs(),
310 "Request timed out, will retry with backoff"
311 );
312 error
313 }
314 };
315
316 if attempt >= self.config.retry_config.max_retries {
318 return Err(last_error);
319 }
320
321 if let Some(delay) = backoff.next_backoff() {
322 tokio::time::sleep(delay).await;
323 } else {
324 return Err(last_error);
325 }
326 }
327 }
328
329 pub fn inner(&self) -> &Client {
331 &self.client
332 }
333
334 pub fn config(&self) -> &ClientConfig {
336 &self.config
337 }
338
339 fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
357 let retry_config = &self.config.retry_config;
358
359 if attempts >= retry_config.max_retries {
361 return false;
362 }
363
364 if let Some(predicate) = retry_config.retry_predicate {
366 return predicate(error);
367 }
368
369 match error {
371 OdosError::RateLimit { .. } => false,
373
374 OdosError::Api { status, .. } if status.is_client_error() => false,
376
377 OdosError::Api { status, .. } if status.is_server_error() => {
379 retry_config.retry_server_errors
380 }
381
382 OdosError::Http(err) => err.is_timeout() || err.is_connect() || err.is_request(),
384
385 OdosError::Timeout(_) => true,
387
388 _ => false,
390 }
391 }
392}
393
394fn extract_retry_after(response: &Response) -> Option<Duration> {
396 response
397 .headers()
398 .get("retry-after")
399 .and_then(|v| v.to_str().ok())
400 .and_then(|s| s.parse::<u64>().ok())
401 .map(Duration::from_secs)
402}
403
404#[derive(Debug, Clone)]
406struct ParsedErrorResponse {
407 message: String,
409 code: OdosErrorCode,
411 trace_id: Option<crate::error_code::TraceId>,
413}
414
415async fn parse_error_response(response: Response) -> ParsedErrorResponse {
421 let body_text = match response.text().await {
423 Ok(text) => text,
424 Err(e) => {
425 return ParsedErrorResponse {
426 message: format!("Failed to read response body: {}", e),
427 code: OdosErrorCode::Unknown(0),
428 trace_id: None,
429 }
430 }
431 };
432
433 match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
435 Ok(error_response) => {
436 let error_code = OdosErrorCode::from(error_response.error_code);
438 ParsedErrorResponse {
439 message: error_response.detail,
440 code: error_code,
441 trace_id: Some(error_response.trace_id),
442 }
443 }
444 Err(_) => {
445 ParsedErrorResponse {
447 message: body_text,
448 code: OdosErrorCode::Unknown(0),
449 trace_id: None,
450 }
451 }
452 }
453}
454
455impl Default for OdosHttpClient {
456 fn default() -> Self {
468 Self::new().expect("Failed to create default HTTP client")
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use std::sync::{Arc, Mutex};
476 use std::time::Duration;
477 use wiremock::{
478 matchers::{method, path},
479 Mock, MockServer, Request, ResponseTemplate,
480 };
481
482 fn create_retry_mock(
484 first_status: u16,
485 first_body: String,
486 success_after: usize,
487 ) -> impl Fn(&Request) -> ResponseTemplate {
488 let attempt_count = Arc::new(Mutex::new(0));
489 move |_req: &Request| {
490 let mut count = attempt_count.lock().unwrap();
491 *count += 1;
492
493 if *count < success_after {
494 ResponseTemplate::new(first_status).set_body_string(&first_body)
495 } else {
496 ResponseTemplate::new(200).set_body_string("Success")
497 }
498 }
499 }
500
501 fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
503 let config = ClientConfig {
504 timeout: Duration::from_millis(timeout_ms),
505 retry_config: RetryConfig {
506 max_retries,
507 initial_backoff_ms: 10,
508 ..Default::default()
509 },
510 ..Default::default()
511 };
512 OdosHttpClient::with_config(config).unwrap()
513 }
514
515 #[test]
516 fn test_client_config_default() {
517 let config = ClientConfig::default();
518 assert_eq!(config.timeout, Duration::from_secs(30));
519 assert_eq!(config.retry_config.max_retries, 3);
520 assert_eq!(config.max_connections, 20);
521 }
522
523 #[tokio::test]
524 async fn test_client_creation() {
525 let client = OdosHttpClient::new();
526 assert!(client.is_ok());
527 }
528
529 #[tokio::test]
530 async fn test_client_with_custom_config() {
531 let config = ClientConfig {
532 timeout: Duration::from_secs(60),
533 retry_config: RetryConfig {
534 max_retries: 5,
535 ..Default::default()
536 },
537 ..Default::default()
538 };
539 let client = OdosHttpClient::with_config(config.clone());
540 assert!(client.is_ok());
541
542 let client = client.unwrap();
543 assert_eq!(client.config().timeout, Duration::from_secs(60));
544 assert_eq!(client.config().retry_config.max_retries, 5);
545 }
546
547 #[tokio::test]
548 async fn test_rate_limit_with_retry_after() {
549 let mock_server = MockServer::start().await;
550
551 Mock::given(method("GET"))
553 .and(path("/test"))
554 .respond_with(
555 ResponseTemplate::new(429)
556 .set_body_string("Rate limit exceeded")
557 .insert_header("retry-after", "1"),
558 )
559 .expect(1) .mount(&mock_server)
561 .await;
562
563 let client = create_test_client(3, 30000);
564 let response = client
565 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
566 .await;
567
568 assert!(
570 response.is_err(),
571 "Rate limit should return error immediately"
572 );
573
574 if let Err(OdosError::RateLimit {
575 message,
576 retry_after,
577 ..
578 }) = response
579 {
580 assert!(message.contains("Rate limit"));
581 assert_eq!(retry_after, Some(Duration::from_secs(1)));
582 } else {
583 panic!("Expected RateLimit error, got: {response:?}");
584 }
585 }
586
587 #[tokio::test]
588 async fn test_rate_limit_without_retry_after() {
589 let mock_server = MockServer::start().await;
590
591 Mock::given(method("GET"))
593 .and(path("/test"))
594 .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
595 .expect(1) .mount(&mock_server)
597 .await;
598
599 let client = create_test_client(3, 30000);
600 let response = client
601 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
602 .await;
603
604 assert!(
606 response.is_err(),
607 "Rate limit should return error immediately"
608 );
609
610 if let Err(OdosError::RateLimit {
611 message,
612 retry_after,
613 ..
614 }) = response
615 {
616 assert!(message.contains("Rate limit"));
617 assert_eq!(retry_after, None);
618 } else {
619 panic!("Expected RateLimit error, got: {response:?}");
620 }
621 }
622
623 #[tokio::test]
624 async fn test_non_retryable_error() {
625 let mock_server = MockServer::start().await;
626
627 Mock::given(method("GET"))
629 .and(path("/test"))
630 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
631 .expect(1)
632 .mount(&mock_server)
633 .await;
634
635 let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
636
637 let response = client
638 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
639 .await;
640
641 assert!(response.is_err());
643 if let Err(e) = response {
644 assert!(!e.is_retryable());
645 }
646 }
647
648 #[tokio::test]
649 async fn test_retry_exhaustion_returns_last_error() {
650 let mock_server = MockServer::start().await;
651
652 Mock::given(method("GET"))
654 .and(path("/test"))
655 .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
656 .mount(&mock_server)
657 .await;
658
659 let client = create_test_client(2, 30000);
660
661 let response = client
662 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
663 .await;
664
665 assert!(response.is_err());
667 if let Err(e) = response {
668 assert!(
669 matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
670 );
671 }
672 }
673
674 #[tokio::test]
675 async fn test_timeout_error() {
676 let mock_server = MockServer::start().await;
677
678 Mock::given(method("GET"))
680 .and(path("/test"))
681 .respond_with(
682 ResponseTemplate::new(200)
683 .set_body_string("Success")
684 .set_delay(Duration::from_secs(5)),
685 )
686 .mount(&mock_server)
687 .await;
688
689 let client = create_test_client(2, 100);
690
691 let response = client
692 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
693 .await;
694
695 assert!(response.is_err());
697 if let Err(e) = response {
698 let is_timeout = matches!(e, OdosError::Timeout(_))
700 || matches!(e, OdosError::Http(ref err) if err.is_timeout());
701 assert!(is_timeout, "Expected timeout error, got: {e:?}");
702 }
703 }
704
705 #[tokio::test]
706 async fn test_invalid_request_builder_fails_immediately() {
707 let client = OdosHttpClient::default();
708
709 let bad_builder = || {
712 let mut builder = client.inner().get("http://localhost");
713 builder = builder.header("x".repeat(100000), "value");
715 builder
716 };
717
718 let result = client.execute_with_retry(bad_builder).await;
719
720 assert!(result.is_err());
722 if let Err(e) = result {
723 assert!(matches!(e, OdosError::Http(_)));
724 }
725 }
726
727 #[tokio::test]
728 async fn test_retryable_500_error() {
729 let mock_server = MockServer::start().await;
730
731 Mock::given(method("GET"))
732 .and(path("/test"))
733 .respond_with(create_retry_mock(
734 500,
735 "Internal server error".to_string(),
736 2,
737 ))
738 .mount(&mock_server)
739 .await;
740
741 let client = create_test_client(3, 30000);
742 let response = client
743 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
744 .await;
745
746 assert!(response.is_ok(), "500 error should be retried and succeed");
747 }
748
749 #[tokio::test]
750 async fn test_retryable_502_bad_gateway() {
751 let mock_server = MockServer::start().await;
752
753 Mock::given(method("GET"))
754 .and(path("/test"))
755 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
756 .mount(&mock_server)
757 .await;
758
759 let client = create_test_client(3, 30000);
760 let response = client
761 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
762 .await;
763
764 assert!(response.is_ok(), "502 error should be retried and succeed");
765 }
766
767 #[tokio::test]
768 async fn test_retryable_503_service_unavailable() {
769 let mock_server = MockServer::start().await;
770
771 Mock::given(method("GET"))
772 .and(path("/test"))
773 .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
774 .mount(&mock_server)
775 .await;
776
777 let client = create_test_client(3, 30000);
778 let response = client
779 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
780 .await;
781
782 assert!(response.is_ok(), "503 error should be retried and succeed");
783 }
784
785 #[tokio::test]
786 async fn test_retryable_504_gateway_timeout() {
787 let mock_server = MockServer::start().await;
788
789 Mock::given(method("GET"))
790 .and(path("/test"))
791 .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
792 .mount(&mock_server)
793 .await;
794
795 let client = create_test_client(3, 30000);
796 let response = client
797 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
798 .await;
799
800 assert!(response.is_ok(), "504 error should be retried and succeed");
801 }
802
803 #[tokio::test]
804 async fn test_network_error_retryable() {
805 let client = create_test_client(2, 100);
807
808 let response = client
809 .execute_with_retry(|| client.inner().get("http://localhost:1"))
810 .await;
811
812 assert!(response.is_err());
814 if let Err(e) = response {
815 assert!(matches!(e, OdosError::Http(_)));
816 }
817 }
818
819 #[test]
820 fn test_accessor_methods() {
821 let config = ClientConfig {
822 timeout: Duration::from_secs(45),
823 retry_config: RetryConfig {
824 max_retries: 5,
825 ..Default::default()
826 },
827 ..Default::default()
828 };
829 let client = OdosHttpClient::with_config(config.clone()).unwrap();
830
831 assert_eq!(client.config().timeout, Duration::from_secs(45));
833 assert_eq!(client.config().retry_config.max_retries, 5);
834
835 let _inner: &reqwest::Client = client.inner();
837 }
838
839 #[test]
840 fn test_default_client() {
841 let client = OdosHttpClient::default();
842
843 assert_eq!(client.config().timeout, Duration::from_secs(30));
845 assert_eq!(client.config().retry_config.max_retries, 3);
846 }
847
848 #[test]
849 fn test_extract_retry_after_valid_numeric() {
850 let response = reqwest::Response::from(
851 http::Response::builder()
852 .status(429)
853 .header("retry-after", "30")
854 .body("")
855 .unwrap(),
856 );
857
858 let retry_after = extract_retry_after(&response);
859 assert_eq!(retry_after, Some(Duration::from_secs(30)));
860 }
861
862 #[test]
863 fn test_extract_retry_after_missing_header() {
864 let response =
865 reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
866
867 let retry_after = extract_retry_after(&response);
868 assert_eq!(retry_after, None);
869 }
870
871 #[test]
872 fn test_extract_retry_after_malformed_value() {
873 let response = reqwest::Response::from(
874 http::Response::builder()
875 .status(429)
876 .header("retry-after", "not-a-number")
877 .body("")
878 .unwrap(),
879 );
880
881 let retry_after = extract_retry_after(&response);
882 assert_eq!(retry_after, None);
883 }
884
885 #[test]
886 fn test_extract_retry_after_zero_value() {
887 let response = reqwest::Response::from(
888 http::Response::builder()
889 .status(429)
890 .header("retry-after", "0")
891 .body("")
892 .unwrap(),
893 );
894
895 let retry_after = extract_retry_after(&response);
896 assert_eq!(retry_after, Some(Duration::from_secs(0)));
897 }
898
899 #[tokio::test]
900 async fn test_rate_limit_with_retry_after_zero() {
901 let mock_server = MockServer::start().await;
902
903 Mock::given(method("GET"))
905 .and(path("/test"))
906 .respond_with(
907 ResponseTemplate::new(429)
908 .set_body_string("Rate limit exceeded")
909 .insert_header("retry-after", "0"),
910 )
911 .expect(1) .mount(&mock_server)
913 .await;
914
915 let client = create_test_client(3, 30000);
916 let response = client
917 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
918 .await;
919
920 assert!(
922 response.is_err(),
923 "Rate limit should return error immediately"
924 );
925
926 if let Err(OdosError::RateLimit {
927 message,
928 retry_after,
929 ..
930 }) = response
931 {
932 assert!(message.contains("Rate limit"));
933 assert_eq!(retry_after, Some(Duration::from_secs(0)));
934 } else {
935 panic!("Expected RateLimit error, got: {response:?}");
936 }
937 }
938
939 #[test]
940 fn test_extract_retry_after_large_value() {
941 let response = reqwest::Response::from(
942 http::Response::builder()
943 .status(429)
944 .header("retry-after", "3600")
945 .body("")
946 .unwrap(),
947 );
948
949 let retry_after = extract_retry_after(&response);
950 assert_eq!(retry_after, Some(Duration::from_secs(3600)));
951 }
952
953 #[test]
954 fn test_extract_retry_after_invalid_utf8() {
955 let response = reqwest::Response::from(
956 http::Response::builder()
957 .status(429)
958 .header("retry-after", vec![0xff, 0xfe])
959 .body("")
960 .unwrap(),
961 );
962
963 let retry_after = extract_retry_after(&response);
964 assert_eq!(retry_after, None);
965 }
966
967 #[test]
968 fn test_client_config_debug_redacts_api_key() {
969 use crate::ApiKey;
970 use uuid::Uuid;
971
972 let uuid = Uuid::new_v4();
973 let uuid_str = uuid.to_string();
974 let api_key = ApiKey::new(uuid);
975
976 let config = ClientConfig {
977 api_key: Some(api_key),
978 ..Default::default()
979 };
980
981 let debug_output = format!("{:?}", config);
982
983 assert!(debug_output.contains("[REDACTED]"));
985
986 assert!(
988 !debug_output.contains(&uuid_str),
989 "API key UUID should not appear in debug output, but found: {}",
990 uuid_str
991 );
992 }
993
994 #[tokio::test]
995 async fn test_max_retries_zero() {
996 let mock_server = MockServer::start().await;
997
998 Mock::given(method("GET"))
1000 .and(path("/test"))
1001 .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1002 .expect(1) .mount(&mock_server)
1004 .await;
1005
1006 let client = create_test_client(0, 30000); let response = client
1008 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1009 .await;
1010
1011 assert!(response.is_err());
1013 if let Err(e) = response {
1014 assert!(
1015 matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1016 );
1017 }
1018 }
1019
1020 #[tokio::test]
1021 async fn test_parse_structured_error_response() {
1022 use crate::error_code::OdosErrorCode;
1023
1024 let error_json = r#"{
1026 "detail": "Error getting quote, please try again",
1027 "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1028 "errorCode": 2999
1029 }"#;
1030
1031 let http_response = http::Response::builder()
1032 .status(500)
1033 .body(error_json)
1034 .unwrap();
1035 let response = reqwest::Response::from(http_response);
1036
1037 let parsed = parse_error_response(response).await;
1038
1039 assert_eq!(parsed.message, "Error getting quote, please try again");
1040 assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1041 assert!(parsed.trace_id.is_some());
1042 assert_eq!(
1043 parsed.trace_id.unwrap().to_string(),
1044 "10becdc8-a021-4491-8201-a17b657204e0"
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn test_parse_unstructured_error_response() {
1050 let http_response = http::Response::builder()
1052 .status(500)
1053 .body("Internal server error")
1054 .unwrap();
1055 let response = reqwest::Response::from(http_response);
1056
1057 let parsed = parse_error_response(response).await;
1058
1059 assert_eq!(parsed.message, "Internal server error");
1060 assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1061 assert!(parsed.trace_id.is_none());
1062 }
1063
1064 #[tokio::test]
1065 async fn test_api_error_with_structured_response() {
1066 let mock_server = MockServer::start().await;
1067
1068 let error_json = r#"{
1069 "detail": "Invalid chain ID",
1070 "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1071 "errorCode": 4001
1072 }"#;
1073
1074 Mock::given(method("GET"))
1075 .and(path("/test"))
1076 .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1077 .expect(1)
1078 .mount(&mock_server)
1079 .await;
1080
1081 let client = create_test_client(0, 30000);
1082 let response = client
1083 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1084 .await;
1085
1086 assert!(response.is_err());
1087 if let Err(e) = response {
1088 assert!(matches!(e, OdosError::Api { .. }));
1090
1091 let error_code = e.error_code();
1093 assert!(error_code.is_some());
1094 assert!(error_code.unwrap().is_invalid_chain_id());
1095
1096 let trace_id = e.trace_id();
1098 assert!(trace_id.is_some());
1099 } else {
1100 panic!("Expected error, got success");
1101 }
1102 }
1103
1104 #[tokio::test]
1105 async fn test_client_config_failure() {
1106 let config = ClientConfig {
1109 max_connections: usize::MAX,
1110 ..Default::default()
1111 };
1112
1113 let result = OdosHttpClient::with_config(config);
1115
1116 match result {
1119 Ok(_) => {
1120 }
1122 Err(e) => {
1123 assert!(matches!(e, OdosError::Http(_)));
1125 }
1126 }
1127 }
1128
1129 #[tokio::test]
1130 async fn test_rate_limit_with_trace_id() {
1131 let mock_server = MockServer::start().await;
1132
1133 let error_json = r#"{
1134 "detail": "Rate limit exceeded",
1135 "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1136 "errorCode": 4299
1137 }"#;
1138
1139 Mock::given(method("GET"))
1140 .and(path("/test"))
1141 .respond_with(
1142 ResponseTemplate::new(429)
1143 .set_body_string(error_json)
1144 .insert_header("retry-after", "30"),
1145 )
1146 .expect(1)
1147 .mount(&mock_server)
1148 .await;
1149
1150 let client = create_test_client(0, 30000);
1151 let response = client
1152 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1153 .await;
1154
1155 assert!(response.is_err());
1156 if let Err(e) = response {
1157 assert!(e.is_rate_limit());
1159
1160 let trace_id = e.trace_id();
1162 assert!(trace_id.is_some());
1163 assert_eq!(
1164 trace_id.unwrap().to_string(),
1165 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1166 );
1167
1168 let error_msg = e.to_string();
1170 assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1171 assert!(error_msg.contains("[trace:"));
1172 } else {
1173 panic!("Expected error, got success");
1174 }
1175 }
1176}