1use std::collections::HashMap;
36use std::future::Future;
37use std::pin::Pin;
38use std::time::Duration;
39
40use crate::error::{ClientError, ClientResult};
41use crate::streaming::EventStream;
42use crate::transport::Transport;
43
44#[derive(Debug, Clone)]
57pub struct RetryPolicy {
58 pub max_retries: u32,
60 pub initial_backoff: Duration,
62 pub max_backoff: Duration,
64 pub backoff_multiplier: f64,
66}
67
68impl Default for RetryPolicy {
69 fn default() -> Self {
70 Self {
71 max_retries: 3,
72 initial_backoff: Duration::from_millis(500),
73 max_backoff: Duration::from_secs(30),
74 backoff_multiplier: 2.0,
75 }
76 }
77}
78
79impl RetryPolicy {
80 #[must_use]
82 pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
83 self.max_retries = max_retries;
84 self
85 }
86
87 #[must_use]
89 pub const fn with_initial_backoff(mut self, backoff: Duration) -> Self {
90 self.initial_backoff = backoff;
91 self
92 }
93
94 #[must_use]
96 pub const fn with_max_backoff(mut self, max: Duration) -> Self {
97 self.max_backoff = max;
98 self
99 }
100
101 #[must_use]
103 pub const fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
104 self.backoff_multiplier = multiplier;
105 self
106 }
107}
108
109impl ClientError {
112 #[must_use]
119 pub const fn is_retryable(&self) -> bool {
120 match self {
121 Self::Http(_) | Self::HttpClient(_) | Self::Timeout(_) => true,
122 Self::UnexpectedStatus { status, .. } => {
123 matches!(status, 429 | 502 | 503 | 504)
124 }
125 Self::Serialization(_)
127 | Self::Protocol(_)
128 | Self::Transport(_)
129 | Self::InvalidEndpoint(_)
130 | Self::AuthRequired { .. }
131 | Self::ProtocolBindingMismatch(_) => false,
132 }
133 }
134}
135
136fn is_idempotent_method(method: &str) -> bool {
148 matches!(
149 method,
150 "GetTask"
151 | "ListTasks"
152 | "CancelTask"
153 | "SubscribeToTask"
154 | "GetExtendedAgentCard"
155 | "GetTaskPushNotificationConfig"
156 | "ListTaskPushNotificationConfigs"
157 | "DeleteTaskPushNotificationConfig"
158 )
159}
160
161const fn safe_to_retry_non_idempotent(error: &ClientError) -> bool {
171 matches!(
172 error,
173 ClientError::UnexpectedStatus {
174 status: 429 | 503,
175 ..
176 }
177 )
178}
179
180fn retry_delay(
183 last_err: Option<&ClientError>,
184 backoff: Duration,
185 max_backoff: Duration,
186) -> Duration {
187 last_err
188 .and_then(ClientError::retry_after)
189 .map_or_else(|| jittered(backoff), |after| after.min(max_backoff))
190}
191
192pub(crate) struct RetryTransport {
197 inner: Box<dyn Transport>,
198 policy: RetryPolicy,
199}
200
201impl RetryTransport {
202 pub(crate) fn new(inner: Box<dyn Transport>, policy: RetryPolicy) -> Self {
204 Self { inner, policy }
205 }
206}
207
208impl Transport for RetryTransport {
209 fn send_request<'a>(
210 &'a self,
211 method: &'a str,
212 params: serde_json::Value,
213 extra_headers: &'a HashMap<String, String>,
214 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
215 Box::pin(async move {
216 let mut last_err: Option<ClientError> = None;
217 let mut backoff = self.policy.initial_backoff;
218 let idempotent = is_idempotent_method(method);
219
220 let serialized = serde_json::to_vec(¶ms).map_err(ClientError::Serialization)?;
223
224 for attempt in 0..=self.policy.max_retries {
225 if attempt > 0 {
226 let delay = retry_delay(last_err.as_ref(), backoff, self.policy.max_backoff);
227 trace_info!(method, attempt, ?delay, "retrying after backoff");
228 tokio::time::sleep(delay).await;
229 backoff = cap_backoff(
230 backoff,
231 self.policy.backoff_multiplier,
232 self.policy.max_backoff,
233 );
234 }
235
236 let attempt_params: serde_json::Value =
237 serde_json::from_slice(&serialized).map_err(ClientError::Serialization)?;
238
239 match self
240 .inner
241 .send_request(method, attempt_params, extra_headers)
242 .await
243 {
244 Ok(result) => return Ok(result),
245 Err(e)
252 if e.is_retryable() && (idempotent || safe_to_retry_non_idempotent(&e)) =>
253 {
254 trace_warn!(method, attempt, error = %e, "transient error, will retry");
255 last_err = Some(e);
256 }
257 Err(e) => return Err(e),
258 }
259 }
260
261 Err(last_err.expect("at least one attempt was made"))
262 })
263 }
264
265 fn send_streaming_request<'a>(
266 &'a self,
267 method: &'a str,
268 params: serde_json::Value,
269 extra_headers: &'a HashMap<String, String>,
270 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
271 Box::pin(async move {
272 let mut last_err: Option<ClientError> = None;
273 let mut backoff = self.policy.initial_backoff;
274 let idempotent = is_idempotent_method(method);
275
276 let serialized = serde_json::to_vec(¶ms).map_err(ClientError::Serialization)?;
279
280 for attempt in 0..=self.policy.max_retries {
281 if attempt > 0 {
282 let delay = retry_delay(last_err.as_ref(), backoff, self.policy.max_backoff);
283 trace_info!(
284 method,
285 attempt,
286 ?delay,
287 "retrying stream connect after backoff"
288 );
289 tokio::time::sleep(delay).await;
290 backoff = cap_backoff(
291 backoff,
292 self.policy.backoff_multiplier,
293 self.policy.max_backoff,
294 );
295 }
296
297 let attempt_params: serde_json::Value =
298 serde_json::from_slice(&serialized).map_err(ClientError::Serialization)?;
299
300 match self
301 .inner
302 .send_streaming_request(method, attempt_params, extra_headers)
303 .await
304 {
305 Ok(stream) => return Ok(stream),
306 Err(e)
310 if e.is_retryable() && (idempotent || safe_to_retry_non_idempotent(&e)) =>
311 {
312 trace_warn!(method, attempt, error = %e, "transient error, will retry");
313 last_err = Some(e);
314 }
315 Err(e) => return Err(e),
316 }
317 }
318
319 Err(last_err.expect("at least one attempt was made"))
320 })
321 }
322}
323
324fn cap_backoff(current: Duration, multiplier: f64, max: Duration) -> Duration {
331 let next_secs = current.as_secs_f64() * multiplier;
332 Duration::try_from_secs_f64(next_secs).map_or(max, |next| {
337 std::cmp::min(next, max)
342 })
343}
344
345#[allow(clippy::cast_precision_loss)] fn jitter_factor_from_bits(random_bits: u64) -> f64 {
352 (random_bits as f64 / u64::MAX as f64).mul_add(0.5, 0.5)
353}
354
355fn apply_jitter(backoff: Duration, factor: f64) -> Duration {
360 let jittered_secs = backoff.as_secs_f64() * factor;
361 Duration::try_from_secs_f64(jittered_secs).unwrap_or(backoff)
367}
368
369fn jittered(backoff: Duration) -> Duration {
376 use std::hash::{BuildHasher, Hasher};
377 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
378 hasher.write_u128(backoff.as_nanos());
380 let factor = jitter_factor_from_bits(hasher.finish());
381 apply_jitter(backoff, factor)
382}
383
384#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn http_errors_are_retryable() {
392 let e = ClientError::HttpClient("connection refused".into());
393 assert!(e.is_retryable());
394 }
395
396 #[test]
397 fn timeout_is_retryable() {
398 let e = ClientError::Timeout("request timed out".into());
399 assert!(e.is_retryable());
400 }
401
402 #[test]
403 fn status_503_is_retryable() {
404 let e = ClientError::UnexpectedStatus {
405 status: 503,
406 body: "Service Unavailable".into(),
407 retry_after: None,
408 };
409 assert!(e.is_retryable());
410 }
411
412 #[test]
413 fn status_429_is_retryable() {
414 let e = ClientError::UnexpectedStatus {
415 status: 429,
416 body: "Too Many Requests".into(),
417 retry_after: None,
418 };
419 assert!(e.is_retryable());
420 }
421
422 #[test]
423 fn status_404_is_not_retryable() {
424 let e = ClientError::UnexpectedStatus {
425 status: 404,
426 body: "Not Found".into(),
427 retry_after: None,
428 };
429 assert!(!e.is_retryable());
430 }
431
432 #[test]
433 fn serialization_error_is_not_retryable() {
434 let e = ClientError::Serialization(serde_json::from_str::<String>("not json").unwrap_err());
435 assert!(!e.is_retryable());
436 }
437
438 #[test]
439 fn protocol_error_is_not_retryable() {
440 let e = ClientError::Protocol(a2a_protocol_types::A2aError::task_not_found("t1"));
441 assert!(!e.is_retryable());
442 }
443
444 #[test]
445 fn default_retry_policy() {
446 let p = RetryPolicy::default();
447 assert_eq!(p.max_retries, 3);
448 assert_eq!(p.initial_backoff, Duration::from_millis(500));
449 assert_eq!(p.max_backoff, Duration::from_secs(30));
450 assert!((p.backoff_multiplier - 2.0).abs() < f64::EPSILON);
451 }
452
453 #[test]
454 fn cap_backoff_works() {
455 let result = cap_backoff(Duration::from_secs(1), 2.0, Duration::from_secs(5));
456 assert_eq!(result, Duration::from_secs(2));
457
458 let result = cap_backoff(Duration::from_secs(4), 2.0, Duration::from_secs(5));
459 assert_eq!(result, Duration::from_secs(5));
460 }
461
462 #[test]
463 fn status_502_is_retryable() {
464 let e = ClientError::UnexpectedStatus {
465 status: 502,
466 body: "Bad Gateway".into(),
467 retry_after: None,
468 };
469 assert!(e.is_retryable());
470 }
471
472 #[test]
473 fn status_504_is_retryable() {
474 let e = ClientError::UnexpectedStatus {
475 status: 504,
476 body: "Gateway Timeout".into(),
477 retry_after: None,
478 };
479 assert!(e.is_retryable());
480 }
481
482 #[test]
484 fn status_boundary_not_retryable() {
485 for status in [428, 430, 500, 501, 505] {
486 let e = ClientError::UnexpectedStatus {
487 status,
488 body: String::new(),
489 retry_after: None,
490 };
491 assert!(!e.is_retryable(), "status {status} should not be retryable");
492 }
493 }
494
495 #[test]
496 fn retry_policy_builder_methods() {
497 let p = RetryPolicy::default()
498 .with_max_retries(5)
499 .with_initial_backoff(Duration::from_secs(1))
500 .with_max_backoff(Duration::from_secs(60))
501 .with_backoff_multiplier(3.0);
502 assert_eq!(p.max_retries, 5);
503 assert_eq!(p.initial_backoff, Duration::from_secs(1));
504 assert_eq!(p.max_backoff, Duration::from_secs(60));
505 assert!((p.backoff_multiplier - 3.0).abs() < f64::EPSILON);
506 }
507
508 #[test]
509 fn cap_backoff_exact_boundary() {
510 let result = cap_backoff(Duration::from_secs(5), 1.0, Duration::from_secs(5));
512 assert_eq!(result, Duration::from_secs(5));
513
514 let result = cap_backoff(Duration::from_millis(1), 2.0, Duration::from_secs(5));
516 assert_eq!(result, Duration::from_millis(2));
517 }
518
519 #[test]
520 fn cap_backoff_infinity_returns_max() {
521 let max = Duration::from_secs(30);
523 let result = cap_backoff(Duration::from_secs(u64::MAX / 2), f64::MAX, max);
524 assert_eq!(result, max, "infinity should clamp to max");
525 }
526
527 #[test]
528 fn cap_backoff_finite_overflow_returns_max() {
529 let max = Duration::from_secs(30);
534 let result = cap_backoff(Duration::from_secs(10_000_000_000_000_000_000), 10.0, max);
535 assert_eq!(
536 result, max,
537 "finite-but-overflowing backoff should clamp to max"
538 );
539 }
540
541 #[test]
543 fn jittered_backoff_in_expected_range() {
544 let backoff = Duration::from_secs(2);
545 for _ in 0..100 {
547 let result = jittered(backoff);
548 assert!(
549 result >= Duration::from_secs(1),
550 "jittered backoff should be >= backoff/2, got {result:?}"
551 );
552 assert!(
553 result <= backoff,
554 "jittered backoff should be <= backoff, got {result:?}"
555 );
556 }
557 }
558
559 #[test]
561 fn jittered_zero_backoff() {
562 let result = jittered(Duration::ZERO);
563 assert_eq!(result, Duration::ZERO);
564 }
565
566 #[test]
567 fn cap_backoff_nan_returns_max() {
568 let max = Duration::from_secs(30);
569 let result = cap_backoff(Duration::from_secs(0), f64::NAN, max);
570 assert_eq!(result, max, "NaN should clamp to max");
571 }
572
573 #[test]
578 fn jitter_factor_from_bits_zero() {
579 let f = jitter_factor_from_bits(0);
580 assert!(
581 (f - 0.5).abs() < f64::EPSILON,
582 "factor(0) should be 0.5, got {f}"
583 );
584 }
585
586 #[test]
588 fn jitter_factor_from_bits_midpoint() {
589 let f = jitter_factor_from_bits(u64::MAX / 2);
590 assert!(
592 (0.74..=0.76).contains(&f),
593 "factor(u64::MAX/2) should be ~0.75, got {f}"
594 );
595 }
596
597 #[test]
599 fn jitter_factor_from_bits_max() {
600 let f = jitter_factor_from_bits(u64::MAX);
601 assert!(
604 (0.9..=1.0).contains(&f),
605 "factor(u64::MAX) should be ~1.0, got {f}"
606 );
607 }
608
609 #[test]
613 fn jitter_factor_from_bits_always_in_half_to_one() {
614 for bits in [
615 0_u64,
616 1,
617 7,
618 42,
619 1 << 20,
620 1 << 50,
621 u64::MAX / 4,
622 u64::MAX / 2,
623 u64::MAX,
624 ] {
625 let f = jitter_factor_from_bits(bits);
626 assert!(
627 (0.5..=1.0).contains(&f),
628 "factor({bits}) = {f} out of [0.5, 1.0]"
629 );
630 }
631 }
632
633 #[test]
642 fn apply_jitter_normal_factor() {
643 assert_eq!(
645 apply_jitter(Duration::from_secs(2), 0.5),
646 Duration::from_secs(1)
647 );
648 assert_eq!(
650 apply_jitter(Duration::from_secs(4), 0.75),
651 Duration::from_secs(3)
652 );
653 assert_eq!(
655 apply_jitter(Duration::from_secs(5), 1.0),
656 Duration::from_secs(5)
657 );
658 }
659
660 #[test]
664 fn apply_jitter_zero_factor_returns_zero() {
665 assert_eq!(
666 apply_jitter(Duration::from_secs(5), 0.0),
667 Duration::ZERO,
668 "factor=0.0 must produce Duration::ZERO via from_secs_f64 path"
669 );
670 }
671
672 #[test]
676 fn apply_jitter_negative_factor_returns_backoff() {
677 assert_eq!(
678 apply_jitter(Duration::from_secs(3), -0.5),
679 Duration::from_secs(3),
680 "negative factor must short-circuit to backoff"
681 );
682 }
683
684 #[test]
693 fn apply_jitter_infinite_factor_returns_backoff() {
694 assert_eq!(
695 apply_jitter(Duration::from_secs(2), f64::INFINITY),
696 Duration::from_secs(2),
697 "infinite factor must short-circuit to backoff"
698 );
699 }
700
701 #[test]
702 fn apply_jitter_nan_factor_returns_backoff() {
703 assert_eq!(
704 apply_jitter(Duration::from_secs(4), f64::NAN),
705 Duration::from_secs(4),
706 "NaN factor must short-circuit to backoff"
707 );
708 }
709
710 use std::collections::HashMap;
713 use std::future::Future;
714 use std::pin::Pin;
715 use std::sync::atomic::{AtomicUsize, Ordering};
716 use std::sync::Arc;
717
718 use crate::streaming::EventStream;
719
720 struct FailNTransport {
722 failures_remaining: Arc<AtomicUsize>,
723 success_response: serde_json::Value,
724 call_count: Arc<AtomicUsize>,
725 }
726
727 impl FailNTransport {
728 fn new(fail_count: usize, response: serde_json::Value) -> Self {
729 Self {
730 failures_remaining: Arc::new(AtomicUsize::new(fail_count)),
731 success_response: response,
732 call_count: Arc::new(AtomicUsize::new(0)),
733 }
734 }
735 }
736
737 impl crate::transport::Transport for FailNTransport {
738 fn send_request<'a>(
739 &'a self,
740 _method: &'a str,
741 _params: serde_json::Value,
742 _extra_headers: &'a HashMap<String, String>,
743 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
744 self.call_count.fetch_add(1, Ordering::SeqCst);
745 let remaining = self.failures_remaining.fetch_sub(1, Ordering::SeqCst);
746 let resp = self.success_response.clone();
747 Box::pin(async move {
748 if remaining > 0 {
749 Err(ClientError::Timeout("transient".into()))
750 } else {
751 Ok(resp)
752 }
753 })
754 }
755
756 fn send_streaming_request<'a>(
757 &'a self,
758 _method: &'a str,
759 _params: serde_json::Value,
760 _extra_headers: &'a HashMap<String, String>,
761 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
762 self.call_count.fetch_add(1, Ordering::SeqCst);
763 let remaining = self.failures_remaining.fetch_sub(1, Ordering::SeqCst);
764 Box::pin(async move {
765 if remaining > 0 {
766 Err(ClientError::Timeout("transient".into()))
767 } else {
768 Err(ClientError::Transport("streaming not mocked".into()))
769 }
770 })
771 }
772 }
773
774 struct NonRetryableErrorTransport {
776 call_count: Arc<AtomicUsize>,
777 }
778
779 impl NonRetryableErrorTransport {
780 fn new() -> Self {
781 Self {
782 call_count: Arc::new(AtomicUsize::new(0)),
783 }
784 }
785 }
786
787 impl crate::transport::Transport for NonRetryableErrorTransport {
788 fn send_request<'a>(
789 &'a self,
790 _method: &'a str,
791 _params: serde_json::Value,
792 _extra_headers: &'a HashMap<String, String>,
793 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
794 self.call_count.fetch_add(1, Ordering::SeqCst);
795 Box::pin(async move { Err(ClientError::InvalidEndpoint("bad url".into())) })
796 }
797
798 fn send_streaming_request<'a>(
799 &'a self,
800 _method: &'a str,
801 _params: serde_json::Value,
802 _extra_headers: &'a HashMap<String, String>,
803 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
804 self.call_count.fetch_add(1, Ordering::SeqCst);
805 Box::pin(async move { Err(ClientError::InvalidEndpoint("bad url".into())) })
806 }
807 }
808
809 #[tokio::test]
810 async fn retry_transport_retries_on_transient_error() {
811 let inner = FailNTransport::new(2, serde_json::json!({"ok": true}));
812 let call_count = Arc::clone(&inner.call_count);
813 let transport = RetryTransport::new(
814 Box::new(inner),
815 RetryPolicy::default()
816 .with_initial_backoff(Duration::from_millis(1))
817 .with_max_retries(3),
818 );
819
820 let headers = HashMap::new();
821 let result = transport
822 .send_request("GetTask", serde_json::Value::Null, &headers)
823 .await;
824 assert!(result.is_ok(), "should succeed after retries");
825 assert_eq!(
826 call_count.load(Ordering::SeqCst),
827 3,
828 "should have made 3 attempts (2 failures + 1 success)"
829 );
830 }
831
832 #[tokio::test]
833 async fn retry_transport_gives_up_after_max_retries() {
834 let inner = FailNTransport::new(10, serde_json::json!({"ok": true}));
836 let call_count = Arc::clone(&inner.call_count);
837 let transport = RetryTransport::new(
838 Box::new(inner),
839 RetryPolicy::default()
840 .with_initial_backoff(Duration::from_millis(1))
841 .with_max_retries(2),
842 );
843
844 let headers = HashMap::new();
845 let result = transport
846 .send_request("GetTask", serde_json::Value::Null, &headers)
847 .await;
848 assert!(result.is_err(), "should fail after exhausting retries");
849 assert_eq!(
850 call_count.load(Ordering::SeqCst),
851 3,
852 "should have made 3 attempts (initial + 2 retries)"
853 );
854 }
855
856 #[tokio::test]
857 async fn retry_transport_no_retry_on_non_retryable() {
858 let inner = NonRetryableErrorTransport::new();
859 let call_count = Arc::clone(&inner.call_count);
860 let transport = RetryTransport::new(
861 Box::new(inner),
862 RetryPolicy::default()
863 .with_initial_backoff(Duration::from_millis(1))
864 .with_max_retries(3),
865 );
866
867 let headers = HashMap::new();
868 let result = transport
869 .send_request("GetTask", serde_json::Value::Null, &headers)
870 .await;
871 assert!(result.is_err());
872 assert!(matches!(
873 result.unwrap_err(),
874 ClientError::InvalidEndpoint(_)
875 ));
876 assert_eq!(
877 call_count.load(Ordering::SeqCst),
878 1,
879 "non-retryable error should not be retried"
880 );
881 }
882
883 #[tokio::test]
884 async fn retry_transport_streaming_retries() {
885 let inner = FailNTransport::new(1, serde_json::json!(null));
886 let call_count = Arc::clone(&inner.call_count);
887 let transport = RetryTransport::new(
888 Box::new(inner),
889 RetryPolicy::default()
890 .with_initial_backoff(Duration::from_millis(1))
891 .with_max_retries(2),
892 );
893
894 let headers = HashMap::new();
895 let result = transport
896 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
897 .await;
898 assert!(result.is_err());
901 assert_eq!(
902 call_count.load(Ordering::SeqCst),
903 2,
904 "should have retried once for streaming"
905 );
906 }
907
908 #[tokio::test]
909 async fn retry_transport_streaming_no_retry_on_non_retryable() {
910 let inner = NonRetryableErrorTransport::new();
911 let call_count = Arc::clone(&inner.call_count);
912 let transport = RetryTransport::new(
913 Box::new(inner),
914 RetryPolicy::default()
915 .with_initial_backoff(Duration::from_millis(1))
916 .with_max_retries(3),
917 );
918
919 let headers = HashMap::new();
920 let result = transport
921 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
922 .await;
923 assert!(matches!(
924 result.unwrap_err(),
925 ClientError::InvalidEndpoint(_)
926 ));
927 assert_eq!(
928 call_count.load(Ordering::SeqCst),
929 1,
930 "non-retryable streaming error should not be retried"
931 );
932 }
933
934 #[tokio::test]
937 async fn retry_transport_streaming_succeeds_after_retry() {
938 use tokio::sync::mpsc;
939
940 struct FailThenStreamTransport {
942 call_count: Arc<AtomicUsize>,
943 }
944
945 impl crate::transport::Transport for FailThenStreamTransport {
946 fn send_request<'a>(
947 &'a self,
948 _method: &'a str,
949 _params: serde_json::Value,
950 _extra_headers: &'a HashMap<String, String>,
951 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>>
952 {
953 Box::pin(async move { Ok(serde_json::Value::Null) })
954 }
955
956 fn send_streaming_request<'a>(
957 &'a self,
958 _method: &'a str,
959 _params: serde_json::Value,
960 _extra_headers: &'a HashMap<String, String>,
961 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
962 let attempt = self.call_count.fetch_add(1, Ordering::SeqCst);
963 Box::pin(async move {
964 if attempt == 0 {
965 Err(ClientError::Timeout("transient timeout".into()))
966 } else {
967 let (tx, rx) = mpsc::channel(8);
969 drop(tx); Ok(EventStream::new(rx))
971 }
972 })
973 }
974 }
975
976 let call_count = Arc::new(AtomicUsize::new(0));
977 let inner = FailThenStreamTransport {
978 call_count: Arc::clone(&call_count),
979 };
980 let transport = RetryTransport::new(
981 Box::new(inner),
982 RetryPolicy::default()
983 .with_initial_backoff(Duration::from_millis(1))
984 .with_max_retries(2),
985 );
986
987 let headers = HashMap::new();
988 let result = transport
989 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
990 .await;
991 assert!(result.is_ok(), "streaming should succeed after retry");
992 assert_eq!(
993 call_count.load(Ordering::SeqCst),
994 2,
995 "should have made 2 attempts (1 failure + 1 success)"
996 );
997 }
998
999 #[tokio::test]
1000 async fn retry_transport_streaming_exhausts_retries() {
1001 let inner = FailNTransport::new(10, serde_json::json!(null));
1002 let call_count = Arc::clone(&inner.call_count);
1003 let transport = RetryTransport::new(
1004 Box::new(inner),
1005 RetryPolicy::default()
1006 .with_initial_backoff(Duration::from_millis(1))
1007 .with_max_retries(2),
1008 );
1009
1010 let headers = HashMap::new();
1011 let result = transport
1012 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
1013 .await;
1014 assert!(result.is_err());
1015 assert_eq!(
1016 call_count.load(Ordering::SeqCst),
1017 3,
1018 "should make 3 attempts total for streaming"
1019 );
1020 }
1021
1022 #[tokio::test]
1023 async fn retry_transport_succeeds_without_retry_on_first_attempt() {
1024 let inner = FailNTransport::new(0, serde_json::json!({"ok": true}));
1025 let call_count = Arc::clone(&inner.call_count);
1026 let transport = RetryTransport::new(
1027 Box::new(inner),
1028 RetryPolicy::default()
1029 .with_initial_backoff(Duration::from_millis(1))
1030 .with_max_retries(3),
1031 );
1032
1033 let headers = HashMap::new();
1034 let result = transport
1035 .send_request("GetTask", serde_json::Value::Null, &headers)
1036 .await;
1037 assert!(result.is_ok());
1038 assert_eq!(
1039 call_count.load(Ordering::SeqCst),
1040 1,
1041 "should succeed on first try"
1042 );
1043 }
1044
1045 #[tokio::test(start_paused = true)]
1051 async fn no_backoff_before_first_attempt() {
1052 let inner = FailNTransport::new(0, serde_json::json!({"ok": true}));
1053 let transport = RetryTransport::new(
1054 Box::new(inner),
1055 RetryPolicy::default()
1056 .with_initial_backoff(Duration::from_secs(100))
1057 .with_max_retries(1),
1058 );
1059
1060 let start = tokio::time::Instant::now();
1061 let headers = HashMap::new();
1062 let result = transport
1063 .send_request("GetTask", serde_json::Value::Null, &headers)
1064 .await;
1065 assert!(result.is_ok());
1066 assert!(
1067 start.elapsed() < Duration::from_secs(1),
1068 "first attempt must not sleep, elapsed: {:?}",
1069 start.elapsed()
1070 );
1071 }
1072
1073 #[tokio::test(start_paused = true)]
1077 async fn backoff_applied_on_retry() {
1078 let inner = FailNTransport::new(1, serde_json::json!({"ok": true}));
1079 let transport = RetryTransport::new(
1080 Box::new(inner),
1081 RetryPolicy::default()
1082 .with_initial_backoff(Duration::from_secs(100))
1083 .with_max_retries(2),
1084 );
1085
1086 let start = tokio::time::Instant::now();
1087 let headers = HashMap::new();
1088 let result = transport
1089 .send_request("GetTask", serde_json::Value::Null, &headers)
1090 .await;
1091 assert!(result.is_ok());
1092 assert!(
1093 start.elapsed() >= Duration::from_secs(50),
1094 "retry should sleep (jittered backoff), elapsed: {:?}",
1095 start.elapsed()
1096 );
1097 }
1098
1099 #[tokio::test(start_paused = true)]
1101 async fn no_backoff_before_first_streaming_attempt() {
1102 use tokio::sync::mpsc;
1103
1104 struct ImmediateStreamTransport;
1105 impl crate::transport::Transport for ImmediateStreamTransport {
1106 fn send_request<'a>(
1107 &'a self,
1108 _method: &'a str,
1109 _params: serde_json::Value,
1110 _extra_headers: &'a HashMap<String, String>,
1111 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>>
1112 {
1113 Box::pin(async { Ok(serde_json::Value::Null) })
1114 }
1115 fn send_streaming_request<'a>(
1116 &'a self,
1117 _method: &'a str,
1118 _params: serde_json::Value,
1119 _extra_headers: &'a HashMap<String, String>,
1120 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
1121 Box::pin(async {
1122 let (tx, rx) = mpsc::channel(1);
1123 drop(tx);
1124 Ok(EventStream::new(rx))
1125 })
1126 }
1127 }
1128
1129 let transport = RetryTransport::new(
1130 Box::new(ImmediateStreamTransport),
1131 RetryPolicy::default()
1132 .with_initial_backoff(Duration::from_secs(100))
1133 .with_max_retries(1),
1134 );
1135
1136 let start = tokio::time::Instant::now();
1137 let headers = HashMap::new();
1138 let result = transport
1139 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
1140 .await;
1141 assert!(result.is_ok());
1142 assert!(
1143 start.elapsed() < Duration::from_secs(1),
1144 "first streaming attempt must not sleep, elapsed: {:?}",
1145 start.elapsed()
1146 );
1147 }
1148
1149 #[tokio::test(start_paused = true)]
1151 async fn backoff_applied_on_streaming_retry() {
1152 let inner = FailNTransport::new(1, serde_json::json!(null));
1153 let transport = RetryTransport::new(
1154 Box::new(inner),
1155 RetryPolicy::default()
1156 .with_initial_backoff(Duration::from_secs(100))
1157 .with_max_retries(2),
1158 );
1159
1160 let start = tokio::time::Instant::now();
1161 let headers = HashMap::new();
1162 let _result = transport
1163 .send_streaming_request("SubscribeToTask", serde_json::Value::Null, &headers)
1164 .await;
1165 assert!(
1168 start.elapsed() >= Duration::from_secs(50),
1169 "streaming retry should sleep, elapsed: {:?}",
1170 start.elapsed()
1171 );
1172 }
1173
1174 #[test]
1179 fn cap_backoff_zero_multiplier_returns_zero() {
1180 let max = Duration::from_secs(30);
1181 let result = cap_backoff(Duration::from_secs(5), 0.0, max);
1182 assert_eq!(
1183 result,
1184 Duration::ZERO,
1185 "0 * any = 0, should not clamp to max"
1186 );
1187 }
1188
1189 #[test]
1192 fn method_idempotency_classification() {
1193 for m in [
1194 "GetTask",
1195 "ListTasks",
1196 "CancelTask",
1197 "SubscribeToTask",
1198 "GetExtendedAgentCard",
1199 "GetTaskPushNotificationConfig",
1200 "ListTaskPushNotificationConfigs",
1201 "DeleteTaskPushNotificationConfig",
1202 ] {
1203 assert!(is_idempotent_method(m), "{m} should be idempotent");
1204 }
1205 for m in [
1206 "SendMessage",
1207 "SendStreamingMessage",
1208 "CreateTaskPushNotificationConfig",
1209 "SomethingBrandNew",
1210 ] {
1211 assert!(!is_idempotent_method(m), "{m} must be non-idempotent");
1212 }
1213 }
1214
1215 #[test]
1216 fn only_429_503_are_safe_for_non_idempotent() {
1217 let mk = |status| ClientError::UnexpectedStatus {
1218 status,
1219 body: String::new(),
1220 retry_after: None,
1221 };
1222 assert!(safe_to_retry_non_idempotent(&mk(429)));
1223 assert!(safe_to_retry_non_idempotent(&mk(503)));
1224 assert!(!safe_to_retry_non_idempotent(&mk(502)));
1226 assert!(!safe_to_retry_non_idempotent(&mk(504)));
1227 assert!(!safe_to_retry_non_idempotent(&ClientError::Timeout(
1228 "t".into()
1229 )));
1230 assert!(!safe_to_retry_non_idempotent(&ClientError::HttpClient(
1231 "reset".into()
1232 )));
1233 }
1234
1235 #[tokio::test]
1238 async fn non_idempotent_not_retried_on_timeout() {
1239 let inner = FailNTransport::new(5, serde_json::json!({"ok": true}));
1240 let call_count = Arc::clone(&inner.call_count);
1241 let transport = RetryTransport::new(
1242 Box::new(inner),
1243 RetryPolicy::default()
1244 .with_initial_backoff(Duration::from_millis(1))
1245 .with_max_retries(3),
1246 );
1247 let headers = HashMap::new();
1248 let result = transport
1249 .send_request("SendMessage", serde_json::Value::Null, &headers)
1250 .await;
1251 assert!(result.is_err());
1252 assert_eq!(
1253 call_count.load(Ordering::SeqCst),
1254 1,
1255 "SendMessage must not be re-sent on an ambiguous timeout"
1256 );
1257 }
1258
1259 #[tokio::test]
1262 async fn non_idempotent_retried_on_503() {
1263 struct Fail503 {
1265 remaining: Arc<AtomicUsize>,
1266 calls: Arc<AtomicUsize>,
1267 }
1268 impl crate::transport::Transport for Fail503 {
1269 fn send_request<'a>(
1270 &'a self,
1271 _m: &'a str,
1272 _p: serde_json::Value,
1273 _h: &'a HashMap<String, String>,
1274 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>>
1275 {
1276 self.calls.fetch_add(1, Ordering::SeqCst);
1277 let left = self.remaining.fetch_sub(1, Ordering::SeqCst);
1278 Box::pin(async move {
1279 if left > 0 {
1280 Err(ClientError::UnexpectedStatus {
1281 status: 503,
1282 body: String::new(),
1283 retry_after: None,
1284 })
1285 } else {
1286 Ok(serde_json::json!({"ok": true}))
1287 }
1288 })
1289 }
1290 fn send_streaming_request<'a>(
1291 &'a self,
1292 _m: &'a str,
1293 _p: serde_json::Value,
1294 _h: &'a HashMap<String, String>,
1295 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
1296 Box::pin(async { Err(ClientError::Transport("n/a".into())) })
1297 }
1298 }
1299 let calls = Arc::new(AtomicUsize::new(0));
1300 let inner = Fail503 {
1301 remaining: Arc::new(AtomicUsize::new(1)),
1302 calls: Arc::clone(&calls),
1303 };
1304 let transport = RetryTransport::new(
1305 Box::new(inner),
1306 RetryPolicy::default()
1307 .with_initial_backoff(Duration::from_millis(1))
1308 .with_max_retries(3),
1309 );
1310 let headers = HashMap::new();
1311 let result = transport
1312 .send_request("SendMessage", serde_json::Value::Null, &headers)
1313 .await;
1314 assert!(result.is_ok(), "503 is a safe retry for non-idempotent");
1315 assert_eq!(calls.load(Ordering::SeqCst), 2);
1316 }
1317
1318 #[tokio::test(start_paused = true)]
1320 async fn retry_after_header_is_honored() {
1321 struct RetryAfter429 {
1323 calls: Arc<AtomicUsize>,
1324 }
1325 impl crate::transport::Transport for RetryAfter429 {
1326 fn send_request<'a>(
1327 &'a self,
1328 _m: &'a str,
1329 _p: serde_json::Value,
1330 _h: &'a HashMap<String, String>,
1331 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>>
1332 {
1333 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1334 Box::pin(async move {
1335 if n == 0 {
1336 Err(ClientError::UnexpectedStatus {
1337 status: 429,
1338 body: String::new(),
1339 retry_after: Some(Duration::from_secs(20)),
1340 })
1341 } else {
1342 Ok(serde_json::json!({"ok": true}))
1343 }
1344 })
1345 }
1346 fn send_streaming_request<'a>(
1347 &'a self,
1348 _m: &'a str,
1349 _p: serde_json::Value,
1350 _h: &'a HashMap<String, String>,
1351 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
1352 Box::pin(async { Err(ClientError::Transport("n/a".into())) })
1353 }
1354 }
1355 let calls = Arc::new(AtomicUsize::new(0));
1356 let transport = RetryTransport::new(
1357 Box::new(RetryAfter429 {
1358 calls: Arc::clone(&calls),
1359 }),
1360 RetryPolicy::default()
1363 .with_initial_backoff(Duration::from_millis(1))
1364 .with_max_backoff(Duration::from_secs(30)),
1365 );
1366 let start = tokio::time::Instant::now();
1367 let headers = HashMap::new();
1368 let result = transport
1369 .send_request("GetTask", serde_json::Value::Null, &headers)
1370 .await;
1371 assert!(result.is_ok());
1372 assert!(
1373 start.elapsed() >= Duration::from_secs(20),
1374 "should have waited the server-requested 20s, waited {:?}",
1375 start.elapsed()
1376 );
1377 }
1378
1379 #[test]
1380 fn retry_after_clamped_to_max_backoff() {
1381 let err = ClientError::UnexpectedStatus {
1382 status: 503,
1383 body: String::new(),
1384 retry_after: Some(Duration::from_secs(9999)),
1385 };
1386 let delay = retry_delay(Some(&err), Duration::from_secs(1), Duration::from_secs(30));
1387 assert_eq!(
1388 delay,
1389 Duration::from_secs(30),
1390 "retry-after must be clamped"
1391 );
1392 }
1393}