1use std::error::Error as StdError;
25use std::fmt;
26use std::io;
27use std::ops::ControlFlow;
28use std::time::Duration;
29
30#[derive(Debug, Clone, Copy)]
32pub struct RetryPolicy {
33 pub max_attempts: u32,
47 pub base_delay: Duration,
49 pub max_delay: Duration,
51}
52
53impl RetryPolicy {
54 pub const UPLOAD: RetryPolicy = RetryPolicy {
57 max_attempts: 10,
58 base_delay: Duration::from_millis(50),
59 max_delay: Duration::from_secs(30),
60 };
61
62 pub const PREFLIGHT: RetryPolicy = RetryPolicy {
74 max_attempts: 3,
75 base_delay: Duration::from_millis(200),
76 max_delay: Duration::from_secs(1),
77 };
78
79 pub fn delay_for(&self, next_attempt: u32) -> Duration {
80 let exp = next_attempt.saturating_sub(2);
84 let mult = 1u64.checked_shl(exp).unwrap_or(u64::MAX);
85 let ms = (self.base_delay.as_millis() as u64).saturating_mul(mult);
86 std::cmp::min(Duration::from_millis(ms), self.max_delay)
87 }
88
89 pub fn with_idempotent_floor(self) -> RetryPolicy {
102 self.with_floor(IDEMPOTENT_PUT_ATTEMPTS)
103 }
104
105 pub fn with_floor(self, min: u32) -> RetryPolicy {
109 RetryPolicy {
110 max_attempts: self.max_attempts.max(min),
111 ..self
112 }
113 }
114}
115
116pub const IDEMPOTENT_PUT_ATTEMPTS: u32 = 3;
123
124pub fn retry_sync<T, E, F>(policy: &RetryPolicy, op: F) -> Result<T, E>
133where
134 F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
135{
136 retry_sync_deadline(policy, None, op)
137}
138
139pub fn retry_sync_deadline<T, E, F>(
146 policy: &RetryPolicy,
147 deadline: Option<std::time::Instant>,
148 mut op: F,
149) -> Result<T, E>
150where
151 F: FnMut(u32) -> Result<T, ControlFlow<E, E>>,
152{
153 let max = policy.max_attempts.max(1);
154 let mut attempt: u32 = 1;
155 loop {
156 if attempt > 1 {
157 std::thread::sleep(policy.delay_for(attempt));
158 }
159 match op(attempt) {
160 Ok(v) => return Ok(v),
161 Err(ControlFlow::Break(e)) => return Err(e),
162 Err(ControlFlow::Continue(e)) => {
163 if attempt >= max {
164 return Err(e);
165 }
166 if let Some(deadline) = deadline {
167 if std::time::Instant::now() + policy.delay_for(attempt + 1) > deadline {
171 return Err(e);
172 }
173 }
174 }
175 }
176 attempt += 1;
177 }
178}
179
180pub async fn retry_async<T, E, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T, E>
184where
185 F: FnMut(u32) -> Fut,
186 Fut: std::future::Future<Output = Result<T, ControlFlow<E, E>>>,
187{
188 let max = policy.max_attempts.max(1);
189 let mut attempt: u32 = 1;
190 loop {
191 if attempt > 1 {
192 tokio::time::sleep(policy.delay_for(attempt)).await;
193 }
194 match op(attempt).await {
195 Ok(v) => return Ok(v),
196 Err(ControlFlow::Break(e)) => return Err(e),
197 Err(ControlFlow::Continue(e)) => {
198 if attempt >= max {
199 return Err(e);
200 }
201 }
202 }
203 attempt += 1;
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum SuccessClass {
212 Strict,
215 AllowRedirects,
219}
220
221pub fn retry_http_blocking<F, M>(
248 label: &str,
249 policy: &RetryPolicy,
250 success_class: SuccessClass,
251 mut send: F,
252 error_msg: M,
253) -> anyhow::Result<(reqwest::StatusCode, String)>
254where
255 F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
256 M: Fn(reqwest::StatusCode, &str) -> String,
257{
258 use anyhow::Context as _;
259 retry_sync(policy, |attempt| {
260 match send(attempt) {
261 Ok(resp) => {
262 let status = resp.status();
263 let succeeded = match success_class {
264 SuccessClass::Strict => status.is_success(),
265 SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
266 };
267 let body = resp
268 .text()
269 .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
270 if succeeded {
271 Ok((status, body))
272 } else {
273 let msg = error_msg(status, &body);
274 let inner = anyhow::anyhow!("{msg}");
275 let wrapped = anyhow::Error::new(HttpError::new(
276 std::io::Error::other(inner.to_string()),
277 status.as_u16(),
278 ))
279 .context(inner);
280 if is_retriable(wrapped.as_ref()) {
286 Err(ControlFlow::Continue(wrapped))
287 } else {
288 Err(ControlFlow::Break(wrapped))
289 }
290 }
291 }
292 Err(e) => {
293 let err = anyhow::Error::new(HttpError::from_response(e, None))
297 .context(format!("{label}: HTTP transport error"));
298 if is_retriable(err.as_ref()) {
299 Err(ControlFlow::Continue(err))
300 } else {
301 Err(ControlFlow::Break(err))
302 }
303 }
304 }
305 })
306 .with_context(|| format!("{label}: exhausted retry attempts"))
307}
308
309pub fn retry_http_blocking_bytes<F, M>(
323 label: &str,
324 policy: &RetryPolicy,
325 success_class: SuccessClass,
326 mut send: F,
327 error_msg: M,
328) -> anyhow::Result<(reqwest::StatusCode, Vec<u8>)>
329where
330 F: FnMut(u32) -> Result<reqwest::blocking::Response, reqwest::Error>,
331 M: Fn(reqwest::StatusCode, &str) -> String,
332{
333 use anyhow::Context as _;
334 retry_sync(policy, |attempt| match send(attempt) {
335 Ok(resp) => {
336 let status = resp.status();
337 let succeeded = match success_class {
338 SuccessClass::Strict => status.is_success(),
339 SuccessClass::AllowRedirects => status.is_success() || status.is_redirection(),
340 };
341 let bytes = resp
342 .bytes()
343 .map(|b| b.to_vec())
344 .unwrap_or_else(|e| format!("<failed to read body: {e}>").into_bytes());
345 if succeeded {
346 Ok((status, bytes))
347 } else {
348 let body_text = String::from_utf8_lossy(&bytes).into_owned();
349 let msg = error_msg(status, &body_text);
350 let inner = anyhow::anyhow!("{msg}");
351 let wrapped = anyhow::Error::new(HttpError::new(
352 std::io::Error::other(inner.to_string()),
353 status.as_u16(),
354 ))
355 .context(inner);
356 if is_retriable(wrapped.as_ref()) {
357 Err(ControlFlow::Continue(wrapped))
358 } else {
359 Err(ControlFlow::Break(wrapped))
360 }
361 }
362 }
363 Err(e) => {
364 let err = anyhow::Error::new(HttpError::from_response(e, None))
365 .context(format!("{label}: HTTP transport error"));
366 if is_retriable(err.as_ref()) {
367 Err(ControlFlow::Continue(err))
368 } else {
369 Err(ControlFlow::Break(err))
370 }
371 }
372 })
373 .with_context(|| format!("{label}: exhausted retry attempts"))
374}
375
376pub async fn retry_http_async<F, Fut, M>(
397 label: &str,
398 policy: &RetryPolicy,
399 success_class: SuccessClass,
400 mut send: F,
401 error_msg: M,
402) -> anyhow::Result<reqwest::Response>
403where
404 F: FnMut(u32) -> Fut,
405 Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
406 M: Fn(reqwest::StatusCode, &str) -> String,
407{
408 use anyhow::Context as _;
409 retry_async(policy, |attempt| {
410 let fut = send(attempt);
411 let error_msg = &error_msg;
412 async move {
413 match fut.await {
414 Ok(resp) => {
415 let status = resp.status();
416 let succeeded = match success_class {
417 SuccessClass::Strict => status.is_success(),
418 SuccessClass::AllowRedirects => {
419 status.is_success() || status.is_redirection()
420 }
421 };
422 if succeeded {
423 Ok(resp)
424 } else {
425 let body = resp
426 .text()
427 .await
428 .unwrap_or_else(|e| format!("<failed to read body: {e}>"));
429 let msg = error_msg(status, &body);
430 let inner = anyhow::anyhow!("{msg}");
431 let wrapped = anyhow::Error::new(HttpError::new(
432 std::io::Error::other(inner.to_string()),
433 status.as_u16(),
434 ))
435 .context(inner);
436 if is_retriable(wrapped.as_ref()) {
442 Err(ControlFlow::Continue(wrapped))
443 } else {
444 Err(ControlFlow::Break(wrapped))
445 }
446 }
447 }
448 Err(e) => {
449 let err = anyhow::Error::new(HttpError::from_response(e, None))
453 .context(format!("{label}: HTTP transport error"));
454 if is_retriable(err.as_ref()) {
455 Err(ControlFlow::Continue(err))
456 } else {
457 Err(ControlFlow::Break(err))
458 }
459 }
460 }
461 }
462 })
463 .await
464 .with_context(|| format!("{label}: exhausted retry attempts"))
465}
466
467pub fn classify_http_sync(
475 result: reqwest::Result<reqwest::blocking::Response>,
476) -> Result<reqwest::blocking::Response, ControlFlow<anyhow::Error, anyhow::Error>> {
477 use anyhow::anyhow;
478 match result {
479 Ok(resp) => {
480 let status = resp.status();
481 if status.is_success() || status.is_redirection() {
482 Ok(resp)
483 } else if status.is_server_error() {
484 Err(ControlFlow::Continue(anyhow!(
485 "HTTP {} {}",
486 status.as_u16(),
487 status.canonical_reason().unwrap_or("server error")
488 )))
489 } else {
490 Err(ControlFlow::Break(anyhow!(
492 "HTTP {} {}",
493 status.as_u16(),
494 status.canonical_reason().unwrap_or("client error")
495 )))
496 }
497 }
498 Err(e) => Err(ControlFlow::Continue(anyhow!(e))),
500 }
501}
502
503#[derive(Debug)]
519pub struct HttpError {
520 source: Box<dyn StdError + Send + Sync + 'static>,
523 pub status: u16,
525}
526
527impl HttpError {
528 pub fn new<E>(source: E, status: u16) -> Self
531 where
532 E: StdError + Send + Sync + 'static,
533 {
534 Self {
535 source: Box::new(source),
536 status,
537 }
538 }
539
540 pub fn from_response<E>(err: E, resp: Option<&reqwest::Response>) -> Self
544 where
545 E: StdError + Send + Sync + 'static,
546 {
547 Self::new(err, resp.map(|r| r.status().as_u16()).unwrap_or(0))
548 }
549}
550
551pub fn http_status(err: &anyhow::Error) -> u16 {
557 err.chain()
558 .find_map(|e| e.downcast_ref::<HttpError>().map(|h| h.status))
559 .unwrap_or(0)
560}
561
562impl fmt::Display for HttpError {
563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564 fmt::Display::fmt(&self.source, f)
567 }
568}
569
570impl StdError for HttpError {
571 fn source(&self) -> Option<&(dyn StdError + 'static)> {
572 Some(&*self.source)
573 }
574}
575
576#[derive(Debug)]
582pub struct Retriable(Box<dyn StdError + Send + Sync + 'static>);
583
584impl Retriable {
585 pub fn new<E>(source: E) -> Self
591 where
592 E: StdError + Send + Sync + 'static,
593 {
594 Self(Box::new(source))
595 }
596}
597
598impl fmt::Display for Retriable {
599 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
600 fmt::Display::fmt(&self.0, f)
601 }
602}
603
604impl StdError for Retriable {
605 fn source(&self) -> Option<&(dyn StdError + 'static)> {
606 Some(&*self.0)
607 }
608}
609
610pub fn is_network_error(err: &(dyn StdError + 'static)) -> bool {
639 let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
640 while let Some(e) = cur {
641 if let Some(io_err) = e.downcast_ref::<io::Error>() {
644 match io_err.kind() {
645 io::ErrorKind::UnexpectedEof
646 | io::ErrorKind::TimedOut
647 | io::ErrorKind::ConnectionRefused
648 | io::ErrorKind::ConnectionReset
649 | io::ErrorKind::ConnectionAborted
650 | io::ErrorKind::BrokenPipe => return true,
651 _ => {}
652 }
653 let m = io_err.to_string().to_lowercase();
654 if m == "eof" || m == "unexpected eof" {
655 return true;
656 }
657 }
658
659 let s = e.to_string().to_lowercase();
663 if NETWORK_ERROR_NEEDLES.iter().any(|n| s.contains(n)) {
664 return true;
665 }
666
667 cur = e.source();
668 }
669 false
670}
671
672const NETWORK_ERROR_NEEDLES: &[&str] = &[
682 "connection reset",
683 "network is unreachable",
684 "connection closed",
685 "connection refused",
686 "tls handshake timeout",
687 "i/o timeout",
688 "broken pipe",
689 "timeout awaiting response headers",
690 "context deadline exceeded",
691 "operation timed out",
693 "the network connection was aborted",
695 "an existing connection was forcibly closed",
697 "dns error",
704 "failed to lookup address",
707 "no such host is known",
709];
710
711pub fn is_retriable(err: &(dyn StdError + 'static)) -> bool {
723 let mut cur: Option<&(dyn StdError + 'static)> = Some(err);
725 while let Some(e) = cur {
726 if e.is::<Retriable>() {
727 return true;
728 }
729 if let Some(http) = e.downcast_ref::<HttpError>()
730 && status_is_retriable(http.status)
731 {
732 return true;
733 }
734 cur = e.source();
735 }
736
737 is_network_error(err)
739}
740
741pub fn status_is_retriable(status: u16) -> bool {
755 status >= 500 || status == 429
756}
757
758pub fn is_retriable_opt(err: Option<&(dyn StdError + 'static)>) -> bool {
761 err.is_some_and(is_retriable)
762}
763
764pub fn jitter_duration(base: Duration) -> Duration {
775 let nanos = base.as_nanos() as u64;
776 let window = nanos / 5;
778 if window == 0 {
779 return base;
780 }
781 let seed = crate::sde::resolve_now().timestamp_subsec_nanos() as u64;
788 let offset = seed % (window * 2);
789 let jittered = nanos.saturating_sub(window).saturating_add(offset);
791 Duration::from_nanos(jittered)
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use std::sync::atomic::{AtomicU32, Ordering};
798
799 fn fast_policy() -> RetryPolicy {
800 RetryPolicy {
801 max_attempts: 4,
802 base_delay: Duration::from_millis(1),
803 max_delay: Duration::from_millis(5),
804 }
805 }
806
807 #[test]
812 fn preflight_policy_is_shallow() {
813 let p = RetryPolicy::PREFLIGHT;
814 assert_eq!(p.max_attempts, 3);
815 assert_eq!(p.base_delay, Duration::from_millis(200));
816 assert_eq!(p.max_delay, Duration::from_secs(1));
817 let total_sleep: Duration = (2..=p.max_attempts).map(|n| p.delay_for(n)).sum();
820 assert!(
821 total_sleep < Duration::from_secs(1),
822 "preflight backoff sleeps must stay sub-second, got {total_sleep:?}"
823 );
824 }
825
826 #[test]
827 fn http_status_extracts_status_from_chain() {
828 let wrapped = anyhow::Error::new(HttpError::new(std::io::Error::other("boom"), 429))
829 .context("outer context");
830 assert_eq!(http_status(&wrapped), 429);
831 }
832
833 #[test]
834 fn http_status_is_zero_without_http_error() {
835 let plain = anyhow::anyhow!("not an http error");
836 assert_eq!(http_status(&plain), 0);
837 }
838
839 #[test]
843 fn idempotent_floor_raises_low_cap_and_preserves_high_cap() {
844 let raised = RetryPolicy {
845 max_attempts: 1,
846 base_delay: Duration::from_millis(1),
847 max_delay: Duration::from_millis(5),
848 }
849 .with_idempotent_floor();
850 assert_eq!(
851 raised.max_attempts, IDEMPOTENT_PUT_ATTEMPTS,
852 "a single-attempt cap must be raised to the idempotent floor"
853 );
854
855 let preserved = RetryPolicy {
856 max_attempts: 7,
857 base_delay: Duration::from_millis(1),
858 max_delay: Duration::from_millis(5),
859 }
860 .with_idempotent_floor();
861 assert_eq!(
862 preserved.max_attempts, 7,
863 "an operator-set cap above the floor must be preserved, not lowered"
864 );
865 }
866
867 #[test]
868 fn jitter_returns_base_when_window_rounds_to_zero() {
869 for n in 0..5u64 {
873 let base = Duration::from_nanos(n);
874 assert_eq!(
875 jitter_duration(base),
876 base,
877 "sub-5ns base {n} must pass through unjittered"
878 );
879 }
880 }
881
882 #[test]
883 fn jitter_stays_within_plus_minus_twenty_percent() {
884 let base = Duration::from_millis(100);
887 let jittered = jitter_duration(base);
888 let lo = base.mul_f64(0.8);
889 let hi = base.mul_f64(1.2);
890 assert!(
891 jittered >= lo && jittered < hi,
892 "jittered {jittered:?} outside [{lo:?}, {hi:?})"
893 );
894 }
895
896 #[test]
897 fn delay_progression_caps_at_max() {
898 let p = RetryPolicy {
899 max_attempts: 10,
900 base_delay: Duration::from_millis(100),
901 max_delay: Duration::from_millis(500),
902 };
903 assert_eq!(p.delay_for(2), Duration::from_millis(100));
904 assert_eq!(p.delay_for(3), Duration::from_millis(200));
905 assert_eq!(p.delay_for(4), Duration::from_millis(400));
906 assert_eq!(p.delay_for(5), Duration::from_millis(500)); assert_eq!(p.delay_for(8), Duration::from_millis(500)); }
909
910 #[test]
911 fn sync_succeeds_on_first_attempt() {
912 let calls = AtomicU32::new(0);
913 let result: Result<&str, ()> = retry_sync(&fast_policy(), |_| {
914 calls.fetch_add(1, Ordering::SeqCst);
915 Ok("ok")
916 });
917 assert_eq!(result, Ok("ok"));
918 assert_eq!(calls.load(Ordering::SeqCst), 1);
919 }
920
921 #[test]
922 fn sync_retries_until_success() {
923 let calls = AtomicU32::new(0);
924 let result: Result<u32, &str> = retry_sync(&fast_policy(), |attempt| {
925 calls.fetch_add(1, Ordering::SeqCst);
926 if attempt < 3 {
927 Err(ControlFlow::Continue("transient"))
928 } else {
929 Ok(attempt)
930 }
931 });
932 assert_eq!(result, Ok(3));
933 assert_eq!(calls.load(Ordering::SeqCst), 3);
934 }
935
936 #[test]
937 fn sync_break_stops_immediately() {
938 let calls = AtomicU32::new(0);
939 let result: Result<(), &str> = retry_sync(&fast_policy(), |_| {
940 calls.fetch_add(1, Ordering::SeqCst);
941 Err(ControlFlow::Break("fatal"))
942 });
943 assert_eq!(result, Err("fatal"));
944 assert_eq!(calls.load(Ordering::SeqCst), 1);
945 }
946
947 #[test]
948 fn sync_returns_last_error_after_exhaustion() {
949 let calls = AtomicU32::new(0);
950 let result: Result<(), String> = retry_sync(&fast_policy(), |attempt| {
951 calls.fetch_add(1, Ordering::SeqCst);
952 Err(ControlFlow::Continue(format!("fail {attempt}")))
953 });
954 assert_eq!(result, Err("fail 4".to_string()));
955 assert_eq!(calls.load(Ordering::SeqCst), 4);
956 }
957
958 #[test]
959 fn deadline_already_elapsed_stops_after_one_attempt_without_sleeping() {
960 let policy = RetryPolicy {
964 max_attempts: 10,
965 base_delay: Duration::from_secs(10),
966 max_delay: Duration::from_secs(300),
967 };
968 let deadline = std::time::Instant::now();
969 let calls = AtomicU32::new(0);
970 let start = std::time::Instant::now();
971 let result: Result<(), &str> = retry_sync_deadline(&policy, Some(deadline), |_| {
972 calls.fetch_add(1, Ordering::SeqCst);
973 Err(ControlFlow::Continue("transient"))
974 });
975 assert_eq!(result, Err("transient"));
976 assert_eq!(
977 calls.load(Ordering::SeqCst),
978 1,
979 "budget-exhausted retry must call op exactly once"
980 );
981 assert!(
982 start.elapsed() < Duration::from_secs(1),
983 "deadline check must skip the 10s backoff sleep, took {:?}",
984 start.elapsed()
985 );
986 }
987
988 #[test]
989 fn deadline_none_matches_retry_sync_on_success() {
990 let calls = AtomicU32::new(0);
991 let result: Result<u32, &str> = retry_sync_deadline(&fast_policy(), None, |attempt| {
992 calls.fetch_add(1, Ordering::SeqCst);
993 if attempt < 2 {
994 Err(ControlFlow::Continue("transient"))
995 } else {
996 Ok(attempt)
997 }
998 });
999 assert_eq!(result, Ok(2));
1000 assert_eq!(calls.load(Ordering::SeqCst), 2);
1001
1002 let sync_calls = AtomicU32::new(0);
1003 let sync_result: Result<u32, &str> = retry_sync(&fast_policy(), |attempt| {
1004 sync_calls.fetch_add(1, Ordering::SeqCst);
1005 if attempt < 2 {
1006 Err(ControlFlow::Continue("transient"))
1007 } else {
1008 Ok(attempt)
1009 }
1010 });
1011 assert_eq!(sync_result, result);
1012 assert_eq!(sync_calls.load(Ordering::SeqCst), 2);
1013 }
1014
1015 #[test]
1016 fn deadline_far_in_future_does_not_change_behavior() {
1017 let deadline = std::time::Instant::now() + Duration::from_secs(3600);
1018 let calls = AtomicU32::new(0);
1019 let result: Result<u32, &str> =
1020 retry_sync_deadline(&fast_policy(), Some(deadline), |attempt| {
1021 calls.fetch_add(1, Ordering::SeqCst);
1022 if attempt < 3 {
1023 Err(ControlFlow::Continue("transient"))
1024 } else {
1025 Ok(attempt)
1026 }
1027 });
1028 assert_eq!(result, Ok(3));
1029 assert_eq!(calls.load(Ordering::SeqCst), 3);
1030 }
1031
1032 #[tokio::test]
1033 async fn async_retries_until_success() {
1034 let calls = std::sync::Arc::new(AtomicU32::new(0));
1035 let calls_inner = calls.clone();
1036 let result: Result<u32, &str> = retry_async(&fast_policy(), move |attempt| {
1037 let c = calls_inner.clone();
1038 async move {
1039 c.fetch_add(1, Ordering::SeqCst);
1040 if attempt < 2 {
1041 Err(ControlFlow::Continue("transient"))
1042 } else {
1043 Ok(attempt)
1044 }
1045 }
1046 })
1047 .await;
1048 assert_eq!(result, Ok(2));
1049 assert_eq!(calls.load(Ordering::SeqCst), 2);
1050 }
1051
1052 #[derive(Debug)]
1060 struct StrErr(&'static str);
1061 impl fmt::Display for StrErr {
1062 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1063 f.write_str(self.0)
1064 }
1065 }
1066 impl StdError for StrErr {}
1067
1068 #[derive(Debug)]
1069 struct OwnedErr(String);
1070 impl fmt::Display for OwnedErr {
1071 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1072 f.write_str(&self.0)
1073 }
1074 }
1075 impl StdError for OwnedErr {}
1076
1077 #[test]
1078 fn network_error_substrings_match() {
1079 for s in [
1080 "connection reset by peer",
1081 "network is unreachable",
1082 "connection closed unexpectedly",
1083 "connection refused",
1084 "tls handshake timeout",
1085 "i/o timeout",
1086 "CONNECTION RESET",
1087 "TLS Handshake Timeout",
1088 "write: broken pipe",
1089 "net/http: timeout awaiting response headers",
1090 "context deadline exceeded",
1091 "client error (Connect): dns error: failed to lookup address information: Name or service not known",
1096 "dns error: nodename nor servname provided, or not known",
1097 "dns error: No such host is known. (os error 11001)",
1098 ] {
1099 let e = OwnedErr(s.to_string());
1100 assert!(is_network_error(&e), "expected network error: {s:?}");
1101 }
1102 }
1103
1104 #[test]
1105 fn network_error_io_eof_kinds() {
1106 let e = io::Error::from(io::ErrorKind::UnexpectedEof);
1107 assert!(is_network_error(&e));
1108
1109 let e2 = io::Error::other("EOF");
1111 assert!(is_network_error(&e2));
1112 }
1113
1114 #[test]
1121 fn is_network_error_classifies_io_timedout() {
1122 let e = io::Error::from(io::ErrorKind::TimedOut);
1123 assert!(is_network_error(&e));
1124 assert!(is_retriable(&e));
1125 }
1126
1127 #[test]
1128 fn is_network_error_classifies_io_connection_refused() {
1129 let e = io::Error::from(io::ErrorKind::ConnectionRefused);
1130 assert!(is_network_error(&e));
1131 assert!(is_retriable(&e));
1132 }
1133
1134 #[test]
1135 fn is_network_error_classifies_io_connection_reset() {
1136 let e = io::Error::from(io::ErrorKind::ConnectionReset);
1137 assert!(is_network_error(&e));
1138 assert!(is_retriable(&e));
1139 }
1140
1141 #[test]
1142 fn is_network_error_classifies_io_connection_aborted() {
1143 let e = io::Error::from(io::ErrorKind::ConnectionAborted);
1144 assert!(is_network_error(&e));
1145 assert!(is_retriable(&e));
1146 }
1147
1148 #[test]
1149 fn is_network_error_classifies_io_broken_pipe() {
1150 let e = io::Error::from(io::ErrorKind::BrokenPipe);
1151 assert!(is_network_error(&e));
1152 assert!(is_retriable(&e));
1153 }
1154
1155 #[test]
1156 fn is_network_error_classifies_operation_timed_out_substring() {
1157 let other_kind = io::Error::other("operation timed out");
1162 assert!(is_network_error(&other_kind));
1163 assert!(is_retriable(&other_kind));
1164
1165 let kind_only = io::Error::from(io::ErrorKind::TimedOut);
1166 assert!(is_network_error(&kind_only));
1167 assert!(is_retriable(&kind_only));
1168 }
1169
1170 #[test]
1171 fn network_error_wrapped_unexpected_eof() {
1172 #[derive(Debug)]
1174 struct Wrap(io::Error);
1175 impl fmt::Display for Wrap {
1176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1177 write!(f, "read failed")
1178 }
1179 }
1180 impl StdError for Wrap {
1181 fn source(&self) -> Option<&(dyn StdError + 'static)> {
1182 Some(&self.0)
1183 }
1184 }
1185 let inner = io::Error::from(io::ErrorKind::UnexpectedEof);
1186 let outer = Wrap(inner);
1187 assert!(is_network_error(&outer));
1188 }
1189
1190 #[test]
1191 fn network_error_non_network_strings_reject() {
1192 for s in [
1193 "file not found",
1194 "permission denied",
1195 "dial tcp: lookup example.com: no such host",
1196 "",
1197 ] {
1198 let e = OwnedErr(s.to_string());
1199 assert!(!is_network_error(&e), "expected NOT network error: {s:?}");
1200 }
1201 }
1202
1203 #[test]
1204 fn retriable_opt_nil_passthrough() {
1205 assert!(!is_retriable_opt(None));
1206 }
1207
1208 #[test]
1209 fn http_error_500_retriable() {
1210 let e = HttpError::new(StrErr("internal server error"), 500);
1211 assert!(is_retriable(&e));
1212 }
1213
1214 #[test]
1215 fn http_error_502_503_retriable() {
1216 for s in [502u16, 503] {
1217 let e = HttpError::new(StrErr("bad gateway"), s);
1218 assert!(is_retriable(&e), "status {s} should be retriable");
1219 }
1220 }
1221
1222 #[test]
1223 fn http_error_429_retriable() {
1224 let e = HttpError::new(StrErr("rate limited"), 429);
1225 assert!(is_retriable(&e));
1226 }
1227
1228 #[test]
1229 fn http_error_4xx_not_retriable() {
1230 for s in [400u16, 401, 403, 404, 422] {
1231 let e = HttpError::new(StrErr("client err"), s);
1232 assert!(!is_retriable(&e), "status {s} should NOT be retriable");
1233 }
1234 }
1235
1236 #[test]
1237 fn http_error_zero_status_routes_via_message() {
1238 let net = HttpError::new(StrErr("connection reset"), 0);
1241 assert!(is_retriable(&net));
1242
1243 let non_net = HttpError::new(StrErr("dial failed"), 0);
1244 assert!(!is_retriable(&non_net));
1245 }
1246
1247 #[test]
1248 fn http_error_unwrap_chain_visible() {
1249 let inner = StrErr("inner");
1250 let e = HttpError::new(inner, 503);
1251 assert!(e.source().is_some());
1252 }
1253
1254 #[test]
1255 fn from_response_nil_resp_yields_status_zero() {
1256 let inner = io::Error::other("connect: dial tcp");
1260 let e = HttpError::from_response(inner, None);
1261 assert_eq!(e.status, 0);
1262 }
1263
1264 #[test]
1265 fn from_response_unwrap_chain_visible() {
1266 let inner = io::Error::other("connection reset by peer");
1269 let e = HttpError::from_response(inner, None);
1270 assert!(
1271 e.source().is_some(),
1272 "inner error must be reachable via source()"
1273 );
1274 assert!(is_retriable(&e));
1276 }
1277
1278 #[test]
1279 fn retriable_wrapper_is_retriable() {
1280 let e = Retriable::new(StrErr("retry me"));
1281 assert!(is_retriable(&e));
1282 }
1283
1284 #[test]
1285 fn retriable_wrapper_overrides_4xx() {
1286 let inner = HttpError::new(StrErr("exists"), 422);
1288 let outer = Retriable::new(inner);
1289 assert!(is_retriable(&outer));
1290 }
1291
1292 #[test]
1293 fn retriable_wrapper_unwrap_chain_visible() {
1294 let inner = StrErr("inner");
1295 let e = Retriable::new(inner);
1296 assert!(e.source().is_some());
1297 }
1298
1299 #[test]
1300 fn plain_error_not_retriable() {
1301 let e = StrErr("something");
1302 assert!(!is_retriable(&e));
1303 }
1304
1305 #[test]
1306 fn anyhow_error_threadable() {
1307 let e: anyhow::Error = anyhow::anyhow!("connection refused");
1310 assert!(is_retriable(e.as_ref()));
1311
1312 let e2: anyhow::Error = anyhow::anyhow!("permission denied");
1313 assert!(!is_retriable(e2.as_ref()));
1314 }
1315
1316 #[test]
1317 fn is_retriable_chain_walks_to_http_error() {
1318 let inner = HttpError::new(StrErr("bad gateway"), 503);
1322 let wrapped: anyhow::Error = anyhow::Error::new(inner).context("publish failed");
1323 assert!(is_retriable(wrapped.as_ref()));
1324 }
1325
1326 #[test]
1337 fn classifier_5xx_via_anyhow_chain_uses_as_ref() {
1338 let wrapped: anyhow::Error =
1339 anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1340 .context("publish");
1341 assert!(
1342 is_retriable(wrapped.as_ref()),
1343 "5xx HttpError reached via as_ref() must classify retriable"
1344 );
1345 }
1346
1347 #[test]
1348 fn classifier_root_cause_walks_past_http_error_drift_guard() {
1349 let wrapped: anyhow::Error =
1354 anyhow::Error::new(HttpError::new(std::io::Error::other("503"), 503))
1355 .context("publish");
1356 assert!(
1357 !is_retriable(wrapped.root_cause()),
1358 "root_cause() walks past HttpError; 5xx must NOT be detected via the leaf"
1359 );
1360 }
1361
1362 #[test]
1363 fn classifier_429_via_anyhow_chain_uses_as_ref() {
1364 let wrapped: anyhow::Error =
1367 anyhow::Error::new(HttpError::new(std::io::Error::other("429"), 429))
1368 .context("publish");
1369 assert!(is_retriable(wrapped.as_ref()));
1370 assert!(!is_retriable(wrapped.root_cause()));
1371 }
1372
1373 use crate::test_helpers::responder::spawn_oneshot_http_responder;
1382
1383 #[test]
1384 fn retry_http_blocking_success_returns_first_attempt() {
1385 let (addr, calls) =
1386 spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1387 let client = reqwest::blocking::Client::builder()
1388 .timeout(Duration::from_secs(2))
1389 .build()
1390 .expect("client");
1391 let policy = RetryPolicy {
1392 max_attempts: 3,
1393 base_delay: Duration::from_millis(1),
1394 max_delay: Duration::from_millis(2),
1395 };
1396 let result = retry_http_blocking(
1397 "test",
1398 &policy,
1399 SuccessClass::Strict,
1400 |_| client.get(format!("http://{addr}/")).send(),
1401 |_, _| String::from("should not be called on success"),
1402 );
1403 let (status, body) = result.expect("success");
1404 assert_eq!(status.as_u16(), 200);
1405 assert_eq!(body, "ok");
1406 assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1407 }
1408
1409 #[test]
1410 fn retry_http_blocking_retries_5xx_then_succeeds() {
1411 let (addr, calls) = spawn_oneshot_http_responder(vec![
1412 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1413 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1414 ]);
1415 let client = reqwest::blocking::Client::builder()
1416 .timeout(Duration::from_secs(2))
1417 .build()
1418 .expect("client");
1419 let policy = RetryPolicy {
1420 max_attempts: 3,
1421 base_delay: Duration::from_millis(1),
1422 max_delay: Duration::from_millis(2),
1423 };
1424 let result = retry_http_blocking(
1425 "test",
1426 &policy,
1427 SuccessClass::Strict,
1428 |_| client.get(format!("http://{addr}/")).send(),
1429 |status, body| format!("{status}: {body}"),
1430 );
1431 let (status, _) = result.expect("eventually succeeds");
1432 assert_eq!(status.as_u16(), 200);
1433 assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1434 }
1435
1436 #[test]
1437 fn retry_http_blocking_4xx_fast_fails_no_retry() {
1438 let (addr, calls) = spawn_oneshot_http_responder(vec![
1439 "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1440 ]);
1441 let client = reqwest::blocking::Client::builder()
1442 .timeout(Duration::from_secs(2))
1443 .build()
1444 .expect("client");
1445 let policy = RetryPolicy {
1446 max_attempts: 5,
1447 base_delay: Duration::from_millis(1),
1448 max_delay: Duration::from_millis(2),
1449 };
1450 let result = retry_http_blocking(
1451 "myscope",
1452 &policy,
1453 SuccessClass::Strict,
1454 |_| client.get(format!("http://{addr}/")).send(),
1455 |status, body| format!("custom error: {status} body={body}"),
1456 );
1457 let err = result.expect_err("4xx must fast-fail");
1458 let chain = format!("{err:#}");
1459 assert!(
1460 chain.contains("custom error"),
1461 "error formatter must be invoked on non-success; got: {chain}"
1462 );
1463 assert!(chain.contains("404"), "status must be in chain: {chain}");
1464 assert_eq!(
1465 calls.load(Ordering::SeqCst),
1466 1,
1467 "4xx must NOT retry (only one connection accepted)"
1468 );
1469 }
1470
1471 #[test]
1472 fn retry_http_blocking_redirect_class_alters_success_predicate() {
1473 let (addr, _calls) = spawn_oneshot_http_responder(vec![
1474 "HTTP/1.1 307 Temporary Redirect\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
1475 ]);
1476 let client = reqwest::blocking::Client::builder()
1477 .timeout(Duration::from_secs(2))
1478 .redirect(reqwest::redirect::Policy::none())
1480 .build()
1481 .expect("client");
1482 let policy = RetryPolicy {
1483 max_attempts: 3,
1484 base_delay: Duration::from_millis(1),
1485 max_delay: Duration::from_millis(2),
1486 };
1487 let result = retry_http_blocking(
1488 "test",
1489 &policy,
1490 SuccessClass::AllowRedirects,
1491 |_| client.get(format!("http://{addr}/")).send(),
1492 |_, _| String::from("should not be called on 3xx with AllowRedirects"),
1493 );
1494 let (status, _) = result.expect("3xx is success under AllowRedirects");
1495 assert_eq!(status.as_u16(), 307);
1496 }
1497
1498 #[test]
1501 fn retry_http_blocking_bytes_preserves_non_utf8_body() {
1502 let body: Vec<u8> = vec![0x1f, 0x8b, 0x08, 0x00, 0x80, 0xff, 0xfe, 0x00];
1508 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
1509 let addr = listener.local_addr().expect("local_addr");
1510 let body_for_thread = body.clone();
1511 std::thread::spawn(move || {
1512 use std::io::{Read, Write};
1513 if let Ok((mut stream, _)) = listener.accept() {
1514 let mut buf = [0u8; 1024];
1515 let _ = stream.read(&mut buf);
1516 let header = format!(
1517 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1518 body_for_thread.len()
1519 );
1520 let _ = stream.write_all(header.as_bytes());
1521 let _ = stream.write_all(&body_for_thread);
1522 let _ = stream.flush();
1523 let _ = stream.shutdown(std::net::Shutdown::Both);
1524 }
1525 });
1526 let client = reqwest::blocking::Client::builder()
1527 .timeout(Duration::from_secs(2))
1528 .build()
1529 .expect("client");
1530 let policy = RetryPolicy {
1531 max_attempts: 1,
1532 base_delay: Duration::from_millis(1),
1533 max_delay: Duration::from_millis(2),
1534 };
1535 let result = retry_http_blocking_bytes(
1536 "test",
1537 &policy,
1538 SuccessClass::Strict,
1539 |_| client.get(format!("http://{addr}/")).send(),
1540 |_, _| String::from("should not be called on success"),
1541 );
1542 let (status, bytes) = result.expect("success");
1543 assert_eq!(status.as_u16(), 200);
1544 assert_eq!(bytes, body, "binary body must round-trip byte-for-byte");
1545 }
1546
1547 #[test]
1548 fn retry_http_blocking_bytes_4xx_fast_fails_no_retry() {
1549 let (addr, calls) = spawn_oneshot_http_responder(vec![
1550 "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1551 ]);
1552 let client = reqwest::blocking::Client::builder()
1553 .timeout(Duration::from_secs(2))
1554 .build()
1555 .expect("client");
1556 let policy = RetryPolicy {
1557 max_attempts: 5,
1558 base_delay: Duration::from_millis(1),
1559 max_delay: Duration::from_millis(2),
1560 };
1561 let result = retry_http_blocking_bytes(
1562 "myscope",
1563 &policy,
1564 SuccessClass::Strict,
1565 |_| client.get(format!("http://{addr}/")).send(),
1566 |status, body| format!("custom error: {status} body={body}"),
1567 );
1568 let err = result.expect_err("4xx must fast-fail");
1569 let chain = format!("{err:#}");
1570 assert!(
1571 chain.contains("custom error") && chain.contains("not found"),
1572 "error formatter must see the (lossily-decoded) error body: {chain}"
1573 );
1574 assert_eq!(
1575 calls.load(Ordering::SeqCst),
1576 1,
1577 "4xx must NOT retry (only one connection accepted)"
1578 );
1579 }
1580
1581 #[test]
1582 fn retry_http_blocking_bytes_retries_5xx_then_succeeds() {
1583 let (addr, calls) = spawn_oneshot_http_responder(vec![
1584 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1585 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1586 ]);
1587 let client = reqwest::blocking::Client::builder()
1588 .timeout(Duration::from_secs(2))
1589 .build()
1590 .expect("client");
1591 let policy = RetryPolicy {
1592 max_attempts: 3,
1593 base_delay: Duration::from_millis(1),
1594 max_delay: Duration::from_millis(2),
1595 };
1596 let result = retry_http_blocking_bytes(
1597 "test",
1598 &policy,
1599 SuccessClass::Strict,
1600 |_| client.get(format!("http://{addr}/")).send(),
1601 |status, body| format!("{status}: {body}"),
1602 );
1603 let (status, bytes) = result.expect("eventually succeeds");
1604 assert_eq!(status.as_u16(), 200);
1605 assert_eq!(bytes, b"ok");
1606 assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1607 }
1608
1609 #[tokio::test]
1620 async fn retry_http_async_success_returns_first_attempt() {
1621 let (addr, calls) =
1622 spawn_oneshot_http_responder(vec!["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]);
1623 let client = reqwest::Client::builder()
1624 .timeout(Duration::from_secs(2))
1625 .build()
1626 .expect("client");
1627 let policy = RetryPolicy {
1628 max_attempts: 3,
1629 base_delay: Duration::from_millis(1),
1630 max_delay: Duration::from_millis(2),
1631 };
1632 let result = retry_http_async(
1633 "test",
1634 &policy,
1635 SuccessClass::Strict,
1636 |_| client.get(format!("http://{addr}/")).send(),
1637 |_, _| String::from("should not be called on success"),
1638 )
1639 .await;
1640 let resp = result.expect("success");
1641 assert_eq!(resp.status().as_u16(), 200);
1642 let body = resp.text().await.expect("body");
1643 assert_eq!(body, "ok");
1644 assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt");
1645 }
1646
1647 #[tokio::test]
1648 async fn retry_http_async_retries_5xx_then_succeeds() {
1649 let (addr, calls) = spawn_oneshot_http_responder(vec![
1650 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
1651 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1652 ]);
1653 let client = reqwest::Client::builder()
1654 .timeout(Duration::from_secs(2))
1655 .build()
1656 .expect("client");
1657 let policy = RetryPolicy {
1658 max_attempts: 3,
1659 base_delay: Duration::from_millis(1),
1660 max_delay: Duration::from_millis(2),
1661 };
1662 let result = retry_http_async(
1663 "test",
1664 &policy,
1665 SuccessClass::Strict,
1666 |_| client.get(format!("http://{addr}/")).send(),
1667 |status, body| format!("{status}: {body}"),
1668 )
1669 .await;
1670 let resp = result.expect("eventually succeeds");
1671 assert_eq!(resp.status().as_u16(), 200);
1672 assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then success");
1673 }
1674
1675 #[tokio::test]
1676 async fn retry_http_async_4xx_fast_fails_no_retry() {
1677 let (addr, calls) = spawn_oneshot_http_responder(vec![
1678 "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found",
1679 ]);
1680 let client = reqwest::Client::builder()
1681 .timeout(Duration::from_secs(2))
1682 .build()
1683 .expect("client");
1684 let policy = RetryPolicy {
1685 max_attempts: 5,
1686 base_delay: Duration::from_millis(1),
1687 max_delay: Duration::from_millis(2),
1688 };
1689 let result = retry_http_async(
1690 "myscope",
1691 &policy,
1692 SuccessClass::Strict,
1693 |_| client.get(format!("http://{addr}/")).send(),
1694 |status, body| format!("custom error: {status} body={body}"),
1695 )
1696 .await;
1697 let err = result.expect_err("4xx must fast-fail");
1698 let chain = format!("{err:#}");
1699 assert!(
1700 chain.contains("custom error"),
1701 "error formatter must be invoked on non-success; got: {chain}"
1702 );
1703 assert!(chain.contains("404"), "status must be in chain: {chain}");
1704 assert_eq!(
1705 calls.load(Ordering::SeqCst),
1706 1,
1707 "4xx must NOT retry (only one connection accepted)"
1708 );
1709 }
1710
1711 #[tokio::test]
1712 async fn retry_http_async_429_retries_then_succeeds() {
1713 let (addr, calls) = spawn_oneshot_http_responder(vec![
1718 "HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n",
1719 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
1720 ]);
1721 let client = reqwest::Client::builder()
1722 .timeout(Duration::from_secs(2))
1723 .build()
1724 .expect("client");
1725 let policy = RetryPolicy {
1726 max_attempts: 3,
1727 base_delay: Duration::from_millis(1),
1728 max_delay: Duration::from_millis(2),
1729 };
1730 let result = retry_http_async(
1731 "test",
1732 &policy,
1733 SuccessClass::Strict,
1734 |_| client.get(format!("http://{addr}/")).send(),
1735 |status, body| format!("{status}: {body}"),
1736 )
1737 .await;
1738 let resp = result.expect("429 retried then success");
1739 assert_eq!(resp.status().as_u16(), 200);
1740 assert_eq!(calls.load(Ordering::SeqCst), 2);
1741 }
1742
1743 const TRANSPORT_FAIL_URL: &str = "http://nonexistent.invalid/";
1767
1768 #[test]
1769 fn retry_http_blocking_transport_error_retries_then_fails() {
1770 let attempts = std::sync::Arc::new(AtomicU32::new(0));
1771 let attempts_inner = attempts.clone();
1772 let client = reqwest::blocking::Client::builder()
1773 .timeout(Duration::from_millis(500))
1774 .build()
1775 .expect("client");
1776 let policy = RetryPolicy {
1777 max_attempts: 3,
1778 base_delay: Duration::from_millis(1),
1779 max_delay: Duration::from_millis(2),
1780 };
1781 let result = retry_http_blocking(
1782 "test-transport",
1783 &policy,
1784 SuccessClass::Strict,
1785 |_| {
1786 attempts_inner.fetch_add(1, Ordering::SeqCst);
1787 client.get(TRANSPORT_FAIL_URL).send()
1788 },
1789 |_, _| String::from("non-success branch should not be reached"),
1790 );
1791 let err = result.expect_err("transport error must surface as Err");
1792 let chain = format!("{err:#}");
1793 assert!(
1794 attempts.load(Ordering::SeqCst) > 1,
1795 "transport error must be retried; got {} attempts; chain={chain}",
1796 attempts.load(Ordering::SeqCst)
1797 );
1798 assert!(
1799 chain.contains("test-transport"),
1800 "label must surface in error chain; got: {chain}"
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn retry_http_async_transport_error_retries_then_fails() {
1806 let attempts = std::sync::Arc::new(AtomicU32::new(0));
1807 let attempts_inner = attempts.clone();
1808 let client = reqwest::Client::builder()
1809 .timeout(Duration::from_millis(500))
1810 .build()
1811 .expect("client");
1812 let policy = RetryPolicy {
1813 max_attempts: 3,
1814 base_delay: Duration::from_millis(1),
1815 max_delay: Duration::from_millis(2),
1816 };
1817 let result = retry_http_async(
1818 "test-transport-async",
1819 &policy,
1820 SuccessClass::Strict,
1821 |_| {
1822 attempts_inner.fetch_add(1, Ordering::SeqCst);
1823 client.get(TRANSPORT_FAIL_URL).send()
1824 },
1825 |_, _| String::from("non-success branch should not be reached"),
1826 )
1827 .await;
1828 let err = result.expect_err("transport error must surface as Err");
1829 assert!(
1830 attempts.load(Ordering::SeqCst) > 1,
1831 "transport error must be retried; got {} attempts",
1832 attempts.load(Ordering::SeqCst)
1833 );
1834 let chain = format!("{err:#}");
1835 assert!(
1836 chain.contains("test-transport-async"),
1837 "label must surface in error chain; got: {chain}"
1838 );
1839 }
1840}