1use crate::error::Error;
2use reqwest::header::HeaderMap;
3use std::time::{Duration, Instant, SystemTime};
4use tokio::time::sleep;
5
6#[derive(Debug, Clone)]
8pub struct RetryConfig {
9 pub max_attempts: usize,
10 pub initial_delay_ms: u64,
11 pub max_delay_ms: u64,
12 pub backoff_multiplier: f64,
13 pub jitter: bool,
14}
15
16impl Default for RetryConfig {
17 fn default() -> Self {
18 Self {
19 max_attempts: 3,
20 initial_delay_ms: 100,
21 max_delay_ms: 5000,
22 backoff_multiplier: 2.0,
23 jitter: true,
24 }
25 }
26}
27
28#[derive(Debug, Clone)]
30pub struct TimeoutConfig {
31 pub connect_timeout_ms: u64,
32 pub request_timeout_ms: u64,
33}
34
35impl Default for TimeoutConfig {
36 fn default() -> Self {
37 Self {
38 connect_timeout_ms: 10_000, request_timeout_ms: 30_000, }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct RetryInfo {
47 pub attempt: u32,
49 pub status_code: Option<u16>,
51 pub delay_ms: u64,
53 pub reason: String,
55}
56
57impl RetryInfo {
58 #[must_use]
60 pub fn new(
61 attempt: u32,
62 status_code: Option<u16>,
63 delay_ms: u64,
64 reason: impl Into<String>,
65 ) -> Self {
66 Self {
67 attempt,
68 status_code,
69 delay_ms,
70 reason: reason.into(),
71 }
72 }
73}
74
75#[derive(Debug)]
77pub struct RetryResult<T> {
78 pub result: Result<T, Error>,
80 pub retry_history: Vec<RetryInfo>,
82 pub total_attempts: u32,
84}
85
86#[must_use]
94pub fn parse_retry_after_header(headers: &HeaderMap) -> Option<Duration> {
95 let retry_after = headers.get("retry-after")?;
96 let value = retry_after.to_str().ok()?;
97 parse_retry_after_value(value)
98}
99
100#[must_use]
109pub fn parse_retry_after_value(value: &str) -> Option<Duration> {
110 if let Ok(seconds) = value.parse::<u64>() {
112 return Some(Duration::from_secs(seconds));
113 }
114
115 httpdate::parse_http_date(value)
119 .ok()
120 .and_then(|date| date.duration_since(SystemTime::now()).ok())
121}
122
123#[must_use]
128#[allow(
129 clippy::cast_precision_loss,
130 clippy::cast_possible_truncation,
131 clippy::cast_sign_loss,
132 clippy::cast_possible_wrap
133)]
134pub fn calculate_retry_delay_with_header(
135 config: &RetryConfig,
136 attempt: usize,
137 retry_after: Option<Duration>,
138) -> Duration {
139 let calculated_delay = calculate_retry_delay(config, attempt);
140
141 retry_after.map_or(calculated_delay, |server_delay| {
142 let delay = calculated_delay.max(server_delay);
144 let max_delay = Duration::from_millis(config.max_delay_ms);
146 delay.min(max_delay)
147 })
148}
149
150#[must_use]
152pub fn is_retryable_error(error: &reqwest::Error) -> bool {
153 if error.is_connect() {
155 return true;
156 }
157
158 if error.is_timeout() {
160 return true;
161 }
162
163 error
165 .status()
166 .is_none_or(|status| is_retryable_status(status.as_u16()))
167}
168
169#[must_use]
176pub const fn is_retryable_status(status: u16) -> bool {
177 match status {
178 408 | 429 => true, 500..=599 => !matches!(status, 501 | 505), _ => false, }
186}
187
188#[must_use]
190#[allow(
191 clippy::cast_precision_loss,
192 clippy::cast_possible_truncation,
193 clippy::cast_sign_loss,
194 clippy::cast_possible_wrap
195)]
196pub fn calculate_retry_delay(config: &RetryConfig, attempt: usize) -> Duration {
197 let base_delay = config.initial_delay_ms as f64;
198 let attempt_i32 = attempt.min(30) as i32; let delay_ms =
200 (base_delay * config.backoff_multiplier.powi(attempt_i32)).min(config.max_delay_ms as f64);
201
202 let final_delay_ms = if config.jitter {
203 let jitter_factor = fastrand::f64().mul_add(0.25, 1.0);
205 delay_ms * jitter_factor
206 } else {
207 delay_ms
208 } as u64;
209
210 Duration::from_millis(final_delay_ms)
211}
212
213pub async fn execute_with_retry<F, Fut, T>(
218 config: &RetryConfig,
219 _operation_name: &str,
220 mut operation: F,
221) -> Result<T, Error>
222where
223 F: FnMut() -> Fut,
224 Fut: std::future::Future<Output = Result<T, reqwest::Error>>,
225{
226 let _start_time = Instant::now();
227 let mut last_error = None;
228
229 for attempt in 0..config.max_attempts {
230 match operation().await {
231 Ok(result) => {
232 return Ok(result);
234 }
235 Err(error) => {
236 let is_last_attempt = attempt + 1 >= config.max_attempts;
237 let is_retryable = is_retryable_error(&error);
238
239 if !is_retryable {
241 let error_message = error.to_string();
242 return Err(Error::transient_network_error(error_message, false));
243 }
244
245 if is_last_attempt {
247 let error_message = error.to_string();
248 last_error = Some(error_message);
249 break;
250 }
251
252 let delay = calculate_retry_delay(config, attempt);
254
255 sleep(delay).await;
256 last_error = Some(error.to_string());
257 }
258 }
259 }
260
261 Err(Error::retry_limit_exceeded(
262 config.max_attempts.try_into().unwrap_or(u32::MAX),
263 last_error.unwrap_or_else(|| "Unknown error".to_string()),
264 ))
265}
266
267#[allow(clippy::cast_possible_truncation)]
272pub async fn execute_with_retry_tracking<F, Fut, T>(
273 config: &RetryConfig,
274 operation_name: &str,
275 mut operation: F,
276) -> RetryResult<T>
277where
278 F: FnMut() -> Fut,
279 Fut: std::future::Future<Output = Result<T, reqwest::Error>>,
280{
281 let mut retry_history = Vec::new();
282 let mut last_error = None;
283
284 for attempt in 0..config.max_attempts {
285 match operation().await {
286 Ok(result) => {
287 return RetryResult {
288 result: Ok(result),
289 retry_history,
290 total_attempts: (attempt + 1) as u32,
291 };
292 }
293 Err(error) => {
294 let is_last_attempt = attempt + 1 >= config.max_attempts;
295 let is_retryable = is_retryable_error(&error);
296 let status_code = error.status().map(|s| s.as_u16());
297 let error_message = error.to_string();
298
299 if !is_retryable {
301 return RetryResult {
302 result: Err(Error::transient_network_error(error_message, false)),
303 retry_history,
304 total_attempts: (attempt + 1) as u32,
305 };
306 }
307
308 if is_last_attempt {
310 last_error = Some(error_message);
311 break;
312 }
313
314 let delay = calculate_retry_delay(config, attempt);
316 let delay_ms = delay.as_millis() as u64;
317
318 retry_history.push(RetryInfo::new(
320 (attempt + 1) as u32,
321 status_code,
322 delay_ms,
323 format!("{operation_name}: {error_message}"),
324 ));
325
326 sleep(delay).await;
328 last_error = Some(error_message);
329 }
330 }
331 }
332
333 RetryResult {
334 result: Err(Error::retry_limit_exceeded(
335 config.max_attempts.try_into().unwrap_or(u32::MAX),
336 last_error.unwrap_or_else(|| "Unknown error".to_string()),
337 )),
338 retry_history,
339 total_attempts: config.max_attempts as u32,
340 }
341}
342
343pub fn create_resilient_client(timeout_config: &TimeoutConfig) -> Result<reqwest::Client, Error> {
348 reqwest::Client::builder()
349 .connect_timeout(Duration::from_millis(timeout_config.connect_timeout_ms))
350 .timeout(Duration::from_millis(timeout_config.request_timeout_ms))
351 .build()
352 .map_err(|e| {
353 Error::network_request_failed(format!("Failed to create resilient HTTP client: {e}"))
354 })
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_calculate_retry_delay() {
363 let config = RetryConfig {
364 max_attempts: 5,
365 initial_delay_ms: 100,
366 max_delay_ms: 1000,
367 backoff_multiplier: 2.0,
368 jitter: false,
369 };
370
371 let delay1 = calculate_retry_delay(&config, 0);
372 let delay2 = calculate_retry_delay(&config, 1);
373 let delay3 = calculate_retry_delay(&config, 2);
374
375 assert_eq!(delay1.as_millis(), 100);
376 assert_eq!(delay2.as_millis(), 200);
377 assert_eq!(delay3.as_millis(), 400);
378
379 let delay_max = calculate_retry_delay(&config, 10);
381 assert_eq!(delay_max.as_millis(), 1000);
382 }
383
384 #[test]
385 fn test_calculate_retry_delay_with_jitter() {
386 let config = RetryConfig {
387 max_attempts: 3,
388 initial_delay_ms: 100,
389 max_delay_ms: 1000,
390 backoff_multiplier: 2.0,
391 jitter: true,
392 };
393
394 let delay1 = calculate_retry_delay(&config, 0);
395 let delay2 = calculate_retry_delay(&config, 0);
396
397 assert!(delay1.as_millis() >= 100 && delay1.as_millis() <= 125);
400 assert!(delay2.as_millis() >= 100 && delay2.as_millis() <= 125);
401 }
402
403 #[test]
404 fn test_default_configs() {
405 let retry_config = RetryConfig::default();
406 assert_eq!(retry_config.max_attempts, 3);
407 assert_eq!(retry_config.initial_delay_ms, 100);
408
409 let timeout_config = TimeoutConfig::default();
410 assert_eq!(timeout_config.connect_timeout_ms, 10_000);
411 assert_eq!(timeout_config.request_timeout_ms, 30_000);
412 }
413
414 #[test]
415 fn test_parse_retry_after_header_seconds() {
416 let mut headers = HeaderMap::new();
417 headers.insert("retry-after", "120".parse().unwrap());
418
419 let duration = parse_retry_after_header(&headers);
420 assert_eq!(duration, Some(Duration::from_secs(120)));
421 }
422
423 #[test]
424 fn test_parse_retry_after_header_zero() {
425 let mut headers = HeaderMap::new();
426 headers.insert("retry-after", "0".parse().unwrap());
427
428 let duration = parse_retry_after_header(&headers);
429 assert_eq!(duration, Some(Duration::from_secs(0)));
430 }
431
432 #[test]
433 fn test_parse_retry_after_header_missing() {
434 let headers = HeaderMap::new();
435
436 let duration = parse_retry_after_header(&headers);
437 assert_eq!(duration, None);
438 }
439
440 #[test]
441 fn test_parse_retry_after_header_invalid() {
442 let mut headers = HeaderMap::new();
443 headers.insert("retry-after", "not-a-number".parse().unwrap());
444
445 let duration = parse_retry_after_header(&headers);
446 assert_eq!(duration, None);
448 }
449
450 #[test]
451 fn test_calculate_retry_delay_with_header_none() {
452 let config = RetryConfig {
453 max_attempts: 3,
454 initial_delay_ms: 100,
455 max_delay_ms: 5000,
456 backoff_multiplier: 2.0,
457 jitter: false,
458 };
459
460 let delay = calculate_retry_delay_with_header(&config, 0, None);
461 assert_eq!(delay.as_millis(), 100);
462 }
463
464 #[test]
465 fn test_calculate_retry_delay_with_header_uses_server_delay_when_larger() {
466 let config = RetryConfig {
467 max_attempts: 3,
468 initial_delay_ms: 100,
469 max_delay_ms: 5000,
470 backoff_multiplier: 2.0,
471 jitter: false,
472 };
473
474 let retry_after = Some(Duration::from_secs(3));
476 let delay = calculate_retry_delay_with_header(&config, 0, retry_after);
477 assert_eq!(delay.as_secs(), 3);
478 }
479
480 #[test]
481 fn test_calculate_retry_delay_with_header_uses_calculated_when_larger() {
482 let config = RetryConfig {
483 max_attempts: 3,
484 initial_delay_ms: 5000,
485 max_delay_ms: 30_000,
486 backoff_multiplier: 2.0,
487 jitter: false,
488 };
489
490 let retry_after = Some(Duration::from_secs(1));
492 let delay = calculate_retry_delay_with_header(&config, 0, retry_after);
493 assert_eq!(delay.as_millis(), 5000);
494 }
495
496 #[test]
497 fn test_calculate_retry_delay_with_header_caps_at_max() {
498 let config = RetryConfig {
499 max_attempts: 3,
500 initial_delay_ms: 100,
501 max_delay_ms: 5000,
502 backoff_multiplier: 2.0,
503 jitter: false,
504 };
505
506 let retry_after = Some(Duration::from_secs(60));
508 let delay = calculate_retry_delay_with_header(&config, 0, retry_after);
509 assert_eq!(delay.as_millis(), 5000);
510 }
511
512 #[test]
513 fn test_retry_info_new() {
514 let info = RetryInfo::new(1, Some(429), 500, "Rate limited");
515 assert_eq!(info.attempt, 1);
516 assert_eq!(info.status_code, Some(429));
517 assert_eq!(info.delay_ms, 500);
518 assert_eq!(info.reason, "Rate limited");
519 }
520
521 #[test]
522 fn test_retry_info_without_status_code() {
523 let info = RetryInfo::new(2, None, 1000, "Connection refused");
524 assert_eq!(info.attempt, 2);
525 assert_eq!(info.status_code, None);
526 assert_eq!(info.delay_ms, 1000);
527 assert_eq!(info.reason, "Connection refused");
528 }
529
530 #[test]
531 fn test_retry_result_success_no_retries() {
532 let result: RetryResult<i32> = RetryResult {
533 result: Ok(42),
534 retry_history: vec![],
535 total_attempts: 1,
536 };
537 assert!(result.result.is_ok());
538 assert!(result.retry_history.is_empty());
539 assert_eq!(result.total_attempts, 1);
540 }
541
542 #[test]
543 fn test_retry_result_success_after_retries() {
544 let result: RetryResult<i32> = RetryResult {
545 result: Ok(42),
546 retry_history: vec![RetryInfo::new(1, Some(503), 100, "Service unavailable")],
547 total_attempts: 2,
548 };
549 assert!(result.result.is_ok());
550 assert_eq!(result.retry_history.len(), 1);
551 assert_eq!(result.total_attempts, 2);
552 }
553
554 #[test]
555 fn test_is_retryable_status_408_request_timeout() {
556 assert!(is_retryable_status(408));
557 }
558
559 #[test]
560 fn test_is_retryable_status_429_too_many_requests() {
561 assert!(is_retryable_status(429));
562 }
563
564 #[test]
565 fn test_is_retryable_status_500_internal_server_error() {
566 assert!(is_retryable_status(500));
567 }
568
569 #[test]
570 fn test_is_retryable_status_502_bad_gateway() {
571 assert!(is_retryable_status(502));
572 }
573
574 #[test]
575 fn test_is_retryable_status_503_service_unavailable() {
576 assert!(is_retryable_status(503));
577 }
578
579 #[test]
580 fn test_is_retryable_status_504_gateway_timeout() {
581 assert!(is_retryable_status(504));
582 }
583
584 #[test]
585 fn test_is_retryable_status_501_not_implemented_not_retryable() {
586 assert!(!is_retryable_status(501));
588 }
589
590 #[test]
591 fn test_is_retryable_status_505_http_version_not_supported_not_retryable() {
592 assert!(!is_retryable_status(505));
594 }
595
596 #[test]
597 fn test_is_retryable_status_4xx_not_retryable() {
598 assert!(!is_retryable_status(400)); assert!(!is_retryable_status(401)); assert!(!is_retryable_status(403)); assert!(!is_retryable_status(404)); assert!(!is_retryable_status(405)); assert!(!is_retryable_status(422)); }
606
607 #[test]
608 fn test_is_retryable_status_2xx_not_retryable() {
609 assert!(!is_retryable_status(200));
611 assert!(!is_retryable_status(201));
612 assert!(!is_retryable_status(204));
613 }
614
615 #[test]
616 fn test_is_retryable_status_3xx_not_retryable() {
617 assert!(!is_retryable_status(301));
619 assert!(!is_retryable_status(302));
620 assert!(!is_retryable_status(304));
621 }
622
623 use std::sync::atomic::{AtomicUsize, Ordering};
630 use std::sync::Arc;
631 use wiremock::matchers::{method, path};
632 use wiremock::{Mock, MockServer, ResponseTemplate};
633
634 struct FailThenSucceed {
636 fail_for: usize,
637 count: Arc<AtomicUsize>,
638 }
639 impl wiremock::Respond for FailThenSucceed {
640 fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
641 let n = self.count.fetch_add(1, Ordering::SeqCst);
642 if n < self.fail_for {
643 ResponseTemplate::new(503)
644 } else {
645 ResponseTemplate::new(200).set_body_string("done")
646 }
647 }
648 }
649
650 fn no_jitter_config(max_attempts: usize) -> RetryConfig {
651 RetryConfig {
652 max_attempts,
653 initial_delay_ms: 1,
654 max_delay_ms: 10,
655 backoff_multiplier: 2.0,
656 jitter: false,
657 }
658 }
659
660 async fn make_request(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
661 let resp = client.get(url).send().await?;
662 let resp = resp.error_for_status()?;
663 let body = resp.text().await?;
664 Ok(body)
665 }
666
667 #[tokio::test]
668 async fn test_execute_with_retry_immediate_success() {
669 let server = MockServer::start().await;
670 Mock::given(method("GET"))
671 .and(path("/ok"))
672 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
673 .expect(1)
674 .mount(&server)
675 .await;
676
677 let client = reqwest::Client::new();
678 let url = format!("{}/ok", server.uri());
679 let config = no_jitter_config(3);
680
681 let result = execute_with_retry(&config, "test", || {
682 let client = client.clone();
683 let url = url.clone();
684 async move { make_request(&client, &url).await }
685 })
686 .await;
687
688 assert!(result.is_ok(), "should succeed on first attempt");
689 assert_eq!(result.unwrap(), "ok");
690 }
691
692 #[tokio::test]
693 async fn test_execute_with_retry_non_retryable_short_circuits() {
694 let server = MockServer::start().await;
695 Mock::given(method("GET"))
697 .and(path("/bad"))
698 .respond_with(ResponseTemplate::new(400))
699 .mount(&server)
700 .await;
701
702 let client = reqwest::Client::new();
703 let url = format!("{}/bad", server.uri());
704 let config = no_jitter_config(3);
705 let call_count = Arc::new(AtomicUsize::new(0));
706
707 let cc = call_count.clone();
708 let result = execute_with_retry(&config, "test", || {
709 let client = client.clone();
710 let url = url.clone();
711 let cc = cc.clone();
712 async move {
713 cc.fetch_add(1, Ordering::SeqCst);
714 make_request(&client, &url).await
715 }
716 })
717 .await;
718
719 assert!(result.is_err(), "non-retryable error must propagate");
720 assert_eq!(call_count.load(Ordering::SeqCst), 1, "must not retry a 400");
721 }
722
723 #[tokio::test]
724 async fn test_execute_with_retry_succeeds_after_transient_failures() {
725 let server = MockServer::start().await;
726 let count = Arc::new(AtomicUsize::new(0));
727 Mock::given(method("GET"))
728 .and(path("/flaky"))
729 .respond_with(FailThenSucceed {
730 fail_for: 2,
731 count: count.clone(),
732 })
733 .expect(3)
734 .mount(&server)
735 .await;
736
737 let client = reqwest::Client::new();
738 let url = format!("{}/flaky", server.uri());
739 let config = no_jitter_config(3);
740
741 let result = execute_with_retry(&config, "test", || {
742 let client = client.clone();
743 let url = url.clone();
744 async move { make_request(&client, &url).await }
745 })
746 .await;
747
748 assert!(result.is_ok(), "should succeed after two transient 503s");
749 assert_eq!(count.load(Ordering::SeqCst), 3, "must have made 3 calls");
750 }
751
752 #[tokio::test]
753 async fn test_execute_with_retry_exhaustion_returns_error() {
754 let server = MockServer::start().await;
755 Mock::given(method("GET"))
756 .and(path("/always-fail"))
757 .respond_with(ResponseTemplate::new(503))
758 .expect(3)
759 .mount(&server)
760 .await;
761
762 let client = reqwest::Client::new();
763 let url = format!("{}/always-fail", server.uri());
764 let config = no_jitter_config(3);
765
766 let result = execute_with_retry(&config, "test", || {
767 let client = client.clone();
768 let url = url.clone();
769 async move { make_request(&client, &url).await }
770 })
771 .await;
772
773 assert!(result.is_err(), "all attempts exhausted must return error");
774 let msg = result.unwrap_err().to_string();
775 assert!(
776 msg.contains("Retry limit exceeded"),
777 "error must mention retry exhaustion, got: {msg}"
778 );
779 }
780
781 #[tokio::test]
784 async fn test_execute_with_retry_tracking_immediate_success() {
785 let server = MockServer::start().await;
786 Mock::given(method("GET"))
787 .and(path("/ok"))
788 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
789 .expect(1)
790 .mount(&server)
791 .await;
792
793 let client = reqwest::Client::new();
794 let url = format!("{}/ok", server.uri());
795 let config = no_jitter_config(3);
796
797 let ret = execute_with_retry_tracking(&config, "test", || {
798 let client = client.clone();
799 let url = url.clone();
800 async move { make_request(&client, &url).await }
801 })
802 .await;
803
804 assert!(ret.result.is_ok());
805 assert_eq!(ret.total_attempts, 1);
806 assert!(
807 ret.retry_history.is_empty(),
808 "no retries on immediate success"
809 );
810 }
811
812 #[tokio::test]
813 async fn test_execute_with_retry_tracking_non_retryable_short_circuits() {
814 let server = MockServer::start().await;
815 Mock::given(method("GET"))
816 .and(path("/bad"))
817 .respond_with(ResponseTemplate::new(400))
818 .expect(1)
819 .mount(&server)
820 .await;
821
822 let client = reqwest::Client::new();
823 let url = format!("{}/bad", server.uri());
824 let config = no_jitter_config(3);
825
826 let ret = execute_with_retry_tracking(&config, "test", || {
827 let client = client.clone();
828 let url = url.clone();
829 async move { make_request(&client, &url).await }
830 })
831 .await;
832
833 assert!(ret.result.is_err());
834 assert_eq!(ret.total_attempts, 1, "must stop after the first attempt");
835 assert!(
836 ret.retry_history.is_empty(),
837 "non-retryable error must not populate retry history"
838 );
839 }
840
841 #[tokio::test]
842 async fn test_execute_with_retry_tracking_records_history() {
843 let server = MockServer::start().await;
844 let count = Arc::new(AtomicUsize::new(0));
845 Mock::given(method("GET"))
846 .and(path("/flaky"))
847 .respond_with(FailThenSucceed {
848 fail_for: 2,
849 count: count.clone(),
850 })
851 .expect(3)
852 .mount(&server)
853 .await;
854
855 let client = reqwest::Client::new();
856 let url = format!("{}/flaky", server.uri());
857 let config = no_jitter_config(3);
858
859 let ret = execute_with_retry_tracking(&config, "test-op", || {
860 let client = client.clone();
861 let url = url.clone();
862 async move { make_request(&client, &url).await }
863 })
864 .await;
865
866 assert!(ret.result.is_ok(), "should eventually succeed");
867 assert_eq!(ret.total_attempts, 3);
868 assert_eq!(ret.retry_history.len(), 2);
870 assert_eq!(ret.retry_history[0].attempt, 1);
872 assert_eq!(ret.retry_history[1].attempt, 2);
873 }
874
875 #[tokio::test]
876 async fn test_execute_with_retry_tracking_exhaustion_total_attempts() {
877 let server = MockServer::start().await;
878 Mock::given(method("GET"))
879 .and(path("/always-fail"))
880 .respond_with(ResponseTemplate::new(503))
881 .expect(3)
882 .mount(&server)
883 .await;
884
885 let client = reqwest::Client::new();
886 let url = format!("{}/always-fail", server.uri());
887 let config = no_jitter_config(3);
888
889 let ret = execute_with_retry_tracking(&config, "test-op", || {
890 let client = client.clone();
891 let url = url.clone();
892 async move { make_request(&client, &url).await }
893 })
894 .await;
895
896 assert!(ret.result.is_err(), "all attempts exhausted");
897 assert_eq!(ret.total_attempts, 3);
898 assert_eq!(ret.retry_history.len(), 2);
900 }
901}