1use std::time::Duration;
42
43use serde::{Deserialize, Serialize};
44
45use crate::error::{AgentError, OperationError};
46
47mod serde_duration_ms {
48 use std::time::Duration;
49
50 use serde::{Deserialize, Deserializer, Serializer};
51
52 pub fn serialize<S: Serializer>(duration: &Duration, s: S) -> Result<S::Ok, S::Error> {
53 let ms = u64::try_from(duration.as_millis())
54 .map_err(|_| serde::ser::Error::custom("duration exceeds u64::MAX milliseconds"))?;
55 s.serialize_u64(ms)
56 }
57
58 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
59 let ms = u64::deserialize(d)?;
60 Ok(Duration::from_millis(ms))
61 }
62}
63
64const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
66
67const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
69
70const DEFAULT_MULTIPLIER: f64 = 2.0;
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(try_from = "RetryPolicyRaw")]
101pub struct RetryPolicy {
102 pub(crate) max_retries: u32,
103 #[serde(serialize_with = "serde_duration_ms::serialize")]
104 pub(crate) initial_backoff: Duration,
105 #[serde(serialize_with = "serde_duration_ms::serialize")]
106 pub(crate) max_backoff: Duration,
107 pub(crate) multiplier: f64,
108}
109
110#[derive(Deserialize)]
112struct RetryPolicyRaw {
113 max_retries: u32,
114 #[serde(deserialize_with = "serde_duration_ms::deserialize")]
115 initial_backoff: Duration,
116 #[serde(deserialize_with = "serde_duration_ms::deserialize")]
117 max_backoff: Duration,
118 multiplier: f64,
119}
120
121impl TryFrom<RetryPolicyRaw> for RetryPolicy {
122 type Error = String;
123
124 fn try_from(raw: RetryPolicyRaw) -> Result<Self, Self::Error> {
125 if raw.max_retries == 0 {
126 return Err("max_retries must be greater than 0".into());
127 }
128 if raw.initial_backoff.is_zero() {
129 return Err("initial backoff must not be zero".into());
130 }
131 if raw.max_backoff.is_zero() {
132 return Err("max backoff must not be zero".into());
133 }
134 if raw.multiplier < 1.0 || !raw.multiplier.is_finite() {
135 return Err(format!(
136 "multiplier must be >= 1.0 and finite, got {}",
137 raw.multiplier
138 ));
139 }
140 Ok(Self {
141 max_retries: raw.max_retries,
142 initial_backoff: raw.initial_backoff,
143 max_backoff: raw.max_backoff,
144 multiplier: raw.multiplier,
145 })
146 }
147}
148
149impl RetryPolicy {
150 pub fn new(max_retries: u32) -> Self {
168 assert!(max_retries > 0, "max_retries must be greater than 0");
169 Self {
170 max_retries,
171 initial_backoff: DEFAULT_INITIAL_BACKOFF,
172 max_backoff: DEFAULT_MAX_BACKOFF,
173 multiplier: DEFAULT_MULTIPLIER,
174 }
175 }
176
177 pub fn backoff(mut self, duration: Duration) -> Self {
185 assert!(!duration.is_zero(), "initial backoff must not be zero");
186 self.initial_backoff = duration;
187 self
188 }
189
190 pub fn max_backoff(mut self, duration: Duration) -> Self {
199 assert!(!duration.is_zero(), "max backoff must not be zero");
200 self.max_backoff = duration;
201 self
202 }
203
204 pub fn multiplier(mut self, multiplier: f64) -> Self {
215 assert!(
216 multiplier >= 1.0 && multiplier.is_finite(),
217 "multiplier must be >= 1.0 and finite, got {multiplier}"
218 );
219 self.multiplier = multiplier;
220 self
221 }
222
223 pub fn max_retries(&self) -> u32 {
225 self.max_retries
226 }
227
228 pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
232 let delay = self.initial_backoff.as_secs_f64() * self.multiplier.powi(attempt as i32);
233 let capped = delay.min(self.max_backoff.as_secs_f64());
234 Duration::from_secs_f64(capped)
235 }
236}
237
238pub fn is_retryable(error: &OperationError) -> bool {
257 match error {
258 OperationError::Http { status, .. } => match status {
259 None => true,
260 Some(code) => *code >= 500 || *code == 429,
261 },
262 OperationError::Agent(agent_err) => match agent_err {
263 AgentError::ProcessFailed { .. }
264 | AgentError::Timeout { .. }
265 | AgentError::SchemaValidation { .. }
266 | AgentError::RateLimited { .. } => true,
267 AgentError::HttpProvider { status_code, .. } => {
268 *status_code == 0 || *status_code >= 500
269 }
270 AgentError::PromptTooLarge { .. } | AgentError::BudgetExceeded { .. } => false,
271 },
272 OperationError::Timeout { .. } => true,
273 OperationError::Shell { .. }
274 | OperationError::Deserialize { .. }
275 | OperationError::Secret { .. }
276 | OperationError::External { .. } => false,
277 }
278}
279
280pub(crate) fn is_retryable_status(status: u16) -> bool {
283 status >= 500 || status == 429
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::time::Duration;
290
291 #[test]
294 fn new_creates_policy_with_defaults() {
295 let policy = RetryPolicy::new(3);
296 assert_eq!(policy.max_retries, 3);
297 assert_eq!(policy.initial_backoff, DEFAULT_INITIAL_BACKOFF);
298 assert_eq!(policy.max_backoff, DEFAULT_MAX_BACKOFF);
299 assert!((policy.multiplier - DEFAULT_MULTIPLIER).abs() < f64::EPSILON);
300 }
301
302 #[test]
303 #[should_panic(expected = "max_retries must be greater than 0")]
304 fn new_zero_retries_panics() {
305 let _ = RetryPolicy::new(0);
306 }
307
308 #[test]
309 fn backoff_sets_initial_backoff() {
310 let policy = RetryPolicy::new(1).backoff(Duration::from_secs(1));
311 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
312 }
313
314 #[test]
315 #[should_panic(expected = "initial backoff must not be zero")]
316 fn backoff_zero_panics() {
317 let _ = RetryPolicy::new(1).backoff(Duration::ZERO);
318 }
319
320 #[test]
321 fn max_backoff_sets_cap() {
322 let policy = RetryPolicy::new(1).max_backoff(Duration::from_secs(60));
323 assert_eq!(policy.max_backoff, Duration::from_secs(60));
324 }
325
326 #[test]
327 #[should_panic(expected = "max backoff must not be zero")]
328 fn max_backoff_zero_panics() {
329 let _ = RetryPolicy::new(1).max_backoff(Duration::ZERO);
330 }
331
332 #[test]
333 fn multiplier_sets_value() {
334 let policy = RetryPolicy::new(1).multiplier(3.0);
335 assert!((policy.multiplier - 3.0).abs() < f64::EPSILON);
336 }
337
338 #[test]
339 #[should_panic(expected = "multiplier must be >= 1.0")]
340 fn multiplier_below_one_panics() {
341 let _ = RetryPolicy::new(1).multiplier(0.5);
342 }
343
344 #[test]
345 #[should_panic(expected = "multiplier must be >= 1.0 and finite")]
346 fn multiplier_nan_panics() {
347 let _ = RetryPolicy::new(1).multiplier(f64::NAN);
348 }
349
350 #[test]
351 #[should_panic(expected = "multiplier must be >= 1.0 and finite")]
352 fn multiplier_infinity_panics() {
353 let _ = RetryPolicy::new(1).multiplier(f64::INFINITY);
354 }
355
356 #[test]
357 fn max_retries_accessor() {
358 assert_eq!(RetryPolicy::new(5).max_retries(), 5);
359 }
360
361 #[test]
364 fn delay_for_attempt_zero_is_initial_backoff() {
365 let policy = RetryPolicy::new(3).backoff(Duration::from_millis(100));
366 let delay = policy.delay_for_attempt(0);
367 assert_eq!(delay, Duration::from_millis(100));
368 }
369
370 #[test]
371 fn delay_for_attempt_grows_exponentially() {
372 let policy = RetryPolicy::new(5)
373 .backoff(Duration::from_millis(100))
374 .multiplier(2.0);
375
376 assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
377 assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(200));
378 assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(400));
379 assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(800));
380 }
381
382 #[test]
383 fn delay_for_attempt_capped_at_max_backoff() {
384 let policy = RetryPolicy::new(10)
385 .backoff(Duration::from_secs(1))
386 .max_backoff(Duration::from_secs(5))
387 .multiplier(10.0);
388
389 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
391 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(5));
392 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(5));
393 }
394
395 #[test]
396 fn delay_for_attempt_with_multiplier_one_is_constant() {
397 let policy = RetryPolicy::new(3)
398 .backoff(Duration::from_millis(500))
399 .multiplier(1.0);
400
401 assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(500));
402 assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(500));
403 assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(500));
404 }
405
406 #[test]
409 fn http_transport_error_is_retryable() {
410 let err = OperationError::Http {
411 status: None,
412 message: "connection refused".to_string(),
413 };
414 assert!(is_retryable(&err));
415 }
416
417 #[test]
418 fn http_500_is_retryable() {
419 let err = OperationError::Http {
420 status: Some(500),
421 message: "internal server error".to_string(),
422 };
423 assert!(is_retryable(&err));
424 }
425
426 #[test]
427 fn http_502_is_retryable() {
428 let err = OperationError::Http {
429 status: Some(502),
430 message: "bad gateway".to_string(),
431 };
432 assert!(is_retryable(&err));
433 }
434
435 #[test]
436 fn http_503_is_retryable() {
437 let err = OperationError::Http {
438 status: Some(503),
439 message: "service unavailable".to_string(),
440 };
441 assert!(is_retryable(&err));
442 }
443
444 #[test]
445 fn http_429_is_retryable() {
446 let err = OperationError::Http {
447 status: Some(429),
448 message: "too many requests".to_string(),
449 };
450 assert!(is_retryable(&err));
451 }
452
453 #[test]
454 fn http_400_is_not_retryable() {
455 let err = OperationError::Http {
456 status: Some(400),
457 message: "bad request".to_string(),
458 };
459 assert!(!is_retryable(&err));
460 }
461
462 #[test]
463 fn http_404_is_not_retryable() {
464 let err = OperationError::Http {
465 status: Some(404),
466 message: "not found".to_string(),
467 };
468 assert!(!is_retryable(&err));
469 }
470
471 #[test]
472 fn agent_process_failed_is_retryable() {
473 let err = OperationError::Agent(AgentError::ProcessFailed {
474 exit_code: 1,
475 stderr: "crash".to_string(),
476 });
477 assert!(is_retryable(&err));
478 }
479
480 #[test]
481 fn agent_timeout_is_retryable() {
482 let err = OperationError::Agent(AgentError::Timeout {
483 limit: Duration::from_secs(60),
484 });
485 assert!(is_retryable(&err));
486 }
487
488 #[test]
489 fn agent_prompt_too_large_is_not_retryable() {
490 let err = OperationError::Agent(AgentError::PromptTooLarge {
491 chars: 1_000_000,
492 estimated_tokens: 250_000,
493 model_limit: 200_000,
494 });
495 assert!(!is_retryable(&err));
496 }
497
498 #[test]
499 fn agent_budget_exceeded_is_not_retryable() {
500 let err = OperationError::Agent(AgentError::BudgetExceeded {
501 spent_usd: 0.30,
502 limit_usd: 0.25,
503 debug_messages: Vec::new(),
504 partial_usage: Box::default(),
505 });
506 assert!(!is_retryable(&err));
507 }
508
509 #[test]
510 fn agent_schema_validation_is_retryable() {
511 let err = OperationError::Agent(AgentError::SchemaValidation {
512 expected: "object".to_string(),
513 got: "string".to_string(),
514 debug_messages: Vec::new(),
515 partial_usage: Box::default(),
516 raw_response: None,
517 });
518 assert!(is_retryable(&err));
519 }
520
521 #[test]
522 fn operation_timeout_is_retryable() {
523 let err = OperationError::Timeout {
524 step: "fetch".to_string(),
525 limit: Duration::from_secs(30),
526 };
527 assert!(is_retryable(&err));
528 }
529
530 #[test]
531 fn shell_error_is_not_retryable() {
532 let err = OperationError::Shell {
533 exit_code: 1,
534 stderr: "fail".to_string(),
535 };
536 assert!(!is_retryable(&err));
537 }
538
539 #[test]
540 fn deserialize_error_is_not_retryable() {
541 let err = OperationError::Deserialize {
542 target_type: "MyStruct".to_string(),
543 reason: "missing field".to_string(),
544 };
545 assert!(!is_retryable(&err));
546 }
547
548 #[test]
551 fn retryable_status_codes() {
552 assert!(is_retryable_status(500));
553 assert!(is_retryable_status(502));
554 assert!(is_retryable_status(503));
555 assert!(is_retryable_status(504));
556 assert!(is_retryable_status(429));
557 }
558
559 #[test]
560 fn non_retryable_status_codes() {
561 assert!(!is_retryable_status(200));
562 assert!(!is_retryable_status(201));
563 assert!(!is_retryable_status(301));
564 assert!(!is_retryable_status(400));
565 assert!(!is_retryable_status(401));
566 assert!(!is_retryable_status(403));
567 assert!(!is_retryable_status(404));
568 assert!(!is_retryable_status(422));
569 assert!(!is_retryable_status(428));
570 }
571
572 #[test]
575 fn builder_chain_all_methods() {
576 let policy = RetryPolicy::new(5)
577 .backoff(Duration::from_millis(100))
578 .max_backoff(Duration::from_secs(10))
579 .multiplier(3.0);
580
581 assert_eq!(policy.max_retries, 5);
582 assert_eq!(policy.initial_backoff, Duration::from_millis(100));
583 assert_eq!(policy.max_backoff, Duration::from_secs(10));
584 assert!((policy.multiplier - 3.0).abs() < f64::EPSILON);
585 }
586
587 #[test]
588 fn clone_produces_independent_copy() {
589 let policy = RetryPolicy::new(3).backoff(Duration::from_millis(100));
590 let cloned = policy.clone();
591 assert_eq!(policy.max_retries, cloned.max_retries);
592 assert_eq!(policy.initial_backoff, cloned.initial_backoff);
593 }
594
595 #[test]
596 fn debug_does_not_panic() {
597 let policy = RetryPolicy::new(1);
598 let debug = format!("{:?}", policy);
599 assert!(debug.contains("RetryPolicy"));
600 }
601
602 #[test]
605 fn serde_roundtrip() {
606 let policy = RetryPolicy::new(3)
607 .backoff(Duration::from_millis(500))
608 .max_backoff(Duration::from_secs(10))
609 .multiplier(2.5);
610
611 let json = serde_json::to_string(&policy).expect("serialize");
612 let back: RetryPolicy = serde_json::from_str(&json).expect("deserialize");
613
614 assert_eq!(back.max_retries, 3);
615 assert_eq!(back.initial_backoff, Duration::from_millis(500));
616 assert_eq!(back.max_backoff, Duration::from_secs(10));
617 assert!((back.multiplier - 2.5).abs() < f64::EPSILON);
618 }
619
620 #[test]
621 fn serde_duration_is_millis() {
622 let policy = RetryPolicy::new(1).backoff(Duration::from_secs(2));
623 let json = serde_json::to_string(&policy).expect("serialize");
624 assert!(json.contains("2000"), "expected 2000ms, got: {json}");
625 }
626
627 #[test]
628 fn serde_rejects_zero_max_retries() {
629 let json =
630 r#"{"max_retries":0,"initial_backoff":200,"max_backoff":30000,"multiplier":2.0}"#;
631 let err = serde_json::from_str::<RetryPolicy>(json).unwrap_err();
632 assert!(
633 err.to_string()
634 .contains("max_retries must be greater than 0")
635 );
636 }
637
638 #[test]
639 fn serde_rejects_invalid_multiplier() {
640 let json =
641 r#"{"max_retries":3,"initial_backoff":200,"max_backoff":30000,"multiplier":0.5}"#;
642 let err = serde_json::from_str::<RetryPolicy>(json).unwrap_err();
643 assert!(err.to_string().contains("multiplier must be >= 1.0"));
644 }
645
646 #[test]
647 fn serde_rejects_zero_backoff() {
648 let json = r#"{"max_retries":3,"initial_backoff":0,"max_backoff":30000,"multiplier":2.0}"#;
649 let err = serde_json::from_str::<RetryPolicy>(json).unwrap_err();
650 assert!(err.to_string().contains("initial backoff must not be zero"));
651 }
652}