1use crate::error::AgentLoopError;
18use rand::RngExt;
19use std::future::Future;
20use std::time::Duration;
21
22const MAX_RETRY_AFTER_SECS: u64 = 60;
25
26#[derive(Debug, Clone)]
35pub struct LlmRetryConfig {
36 pub max_retries: u32,
38 pub initial_backoff: Duration,
40 pub max_backoff: Duration,
42 pub backoff_multiplier: f64,
44 pub jitter_factor: f64,
47}
48
49impl Default for LlmRetryConfig {
50 fn default() -> Self {
51 Self {
53 max_retries: 2,
54 initial_backoff: Duration::from_secs(1),
55 max_backoff: Duration::from_secs(60),
56 backoff_multiplier: 2.0,
57 jitter_factor: 0.25,
58 }
59 }
60}
61
62impl LlmRetryConfig {
63 pub fn no_retry() -> Self {
65 Self {
66 max_retries: 0,
67 ..Default::default()
68 }
69 }
70
71 pub fn aggressive() -> Self {
73 Self {
74 max_retries: 5,
75 initial_backoff: Duration::from_millis(500),
76 max_backoff: Duration::from_secs(120),
77 backoff_multiplier: 2.0,
78 jitter_factor: 0.25,
79 }
80 }
81
82 pub fn calculate_backoff(&self, attempt: u32) -> Duration {
84 let base_backoff =
85 self.initial_backoff.as_secs_f64() * self.backoff_multiplier.powi(attempt as i32);
86 let capped_backoff = base_backoff.min(self.max_backoff.as_secs_f64());
87
88 let jitter = if self.jitter_factor > 0.0 {
92 let jitter_range = capped_backoff * self.jitter_factor;
93 let jitter_offset = rand::rng().random::<f64>() * 2.0 - 1.0;
97 jitter_range * jitter_offset
98 } else {
99 0.0
100 };
101
102 Duration::from_secs_f64((capped_backoff + jitter).max(0.0))
103 }
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct RateLimitInfo {
109 pub retry_after_secs: Option<u64>,
111 pub requests_remaining: Option<u32>,
113 pub tokens_remaining: Option<u32>,
115 pub requests_reset: Option<String>,
117 pub tokens_reset: Option<String>,
119 pub limit_type: Option<RateLimitType>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum RateLimitType {
126 Requests,
128 InputTokens,
130 OutputTokens,
132 TotalTokens,
134 Unknown,
136}
137
138impl RateLimitInfo {
139 pub fn recommended_wait(&self, config: &LlmRetryConfig, attempt: u32) -> Duration {
142 if let Some(retry_after) = self.retry_after_secs {
143 if retry_after > 0 && retry_after <= MAX_RETRY_AFTER_SECS {
146 return Duration::from_secs(retry_after);
147 }
148 }
149 config.calculate_backoff(attempt)
151 }
152
153 pub fn from_anthropic_headers(headers: &reqwest::header::HeaderMap) -> Self {
155 let mut info = Self::default();
156
157 if let Some(val) = headers.get("retry-after-ms")
160 && let Ok(s) = val.to_str()
161 && let Ok(ms) = s.parse::<u64>()
162 {
163 info.retry_after_secs = Some(ms.div_ceil(1000));
165 }
166
167 if info.retry_after_secs.is_none()
169 && let Some(val) = headers.get("retry-after")
170 && let Ok(s) = val.to_str()
171 {
172 info.retry_after_secs = s.parse().ok();
173 }
174
175 if let Some(val) = headers.get("anthropic-ratelimit-requests-remaining")
177 && let Ok(s) = val.to_str()
178 {
179 info.requests_remaining = s.parse().ok();
180 }
181
182 if let Some(val) = headers.get("anthropic-ratelimit-tokens-remaining")
184 && let Ok(s) = val.to_str()
185 {
186 info.tokens_remaining = s.parse().ok();
187 }
188
189 if let Some(val) = headers.get("anthropic-ratelimit-requests-reset")
191 && let Ok(s) = val.to_str()
192 {
193 info.requests_reset = Some(s.to_string());
194 }
195
196 if let Some(val) = headers.get("anthropic-ratelimit-tokens-reset")
198 && let Ok(s) = val.to_str()
199 {
200 info.tokens_reset = Some(s.to_string());
201 }
202
203 if info.requests_remaining == Some(0) {
205 info.limit_type = Some(RateLimitType::Requests);
206 } else if info.tokens_remaining == Some(0) {
207 info.limit_type = Some(RateLimitType::InputTokens);
208 }
209
210 info
211 }
212
213 pub fn from_openai_headers(headers: &reqwest::header::HeaderMap) -> Self {
215 let mut info = Self::default();
216
217 if let Some(val) = headers.get("retry-after-ms")
219 && let Ok(s) = val.to_str()
220 && let Ok(ms) = s.parse::<u64>()
221 {
222 info.retry_after_secs = Some(ms.div_ceil(1000));
224 }
225
226 if info.retry_after_secs.is_none()
228 && let Some(val) = headers.get("retry-after")
229 && let Ok(s) = val.to_str()
230 {
231 info.retry_after_secs = s.parse().ok();
232 }
233
234 if let Some(val) = headers.get("x-ratelimit-remaining-requests")
236 && let Ok(s) = val.to_str()
237 {
238 info.requests_remaining = s.parse().ok();
239 }
240
241 if let Some(val) = headers.get("x-ratelimit-remaining-tokens")
243 && let Ok(s) = val.to_str()
244 {
245 let val: i64 = s.parse().unwrap_or(-1);
247 if val >= 0 {
248 info.tokens_remaining = Some(val as u32);
249 }
250 }
251
252 if let Some(val) = headers.get("x-ratelimit-reset-requests")
254 && let Ok(s) = val.to_str()
255 {
256 info.requests_reset = Some(s.to_string());
257 if info.retry_after_secs.is_none() {
259 info.retry_after_secs = parse_duration_string(s);
260 }
261 }
262
263 if let Some(val) = headers.get("x-ratelimit-reset-tokens")
265 && let Ok(s) = val.to_str()
266 {
267 info.tokens_reset = Some(s.to_string());
268 }
269
270 if info.requests_remaining == Some(0) {
272 info.limit_type = Some(RateLimitType::Requests);
273 } else if info.tokens_remaining == Some(0) {
274 info.limit_type = Some(RateLimitType::TotalTokens);
275 }
276
277 info
278 }
279}
280
281fn parse_duration_string(s: &str) -> Option<u64> {
283 let s = s.trim();
284 if s.is_empty() {
285 return None;
286 }
287
288 let mut total_secs: u64 = 0;
289 let mut current_num = String::new();
290
291 for c in s.chars() {
292 if c.is_ascii_digit() {
293 current_num.push(c);
294 } else {
295 let num: u64 = current_num.parse().ok()?;
296 current_num.clear();
297
298 match c {
299 'h' => total_secs += num * 3600,
300 'm' => total_secs += num * 60,
301 's' => total_secs += num,
302 _ => return None,
303 }
304 }
305 }
306
307 if total_secs > 0 {
308 Some(total_secs)
309 } else {
310 None
311 }
312}
313
314#[derive(Debug, Clone, Default)]
316pub struct RetryMetadata {
317 pub attempts: u32,
319 pub total_retry_wait: Duration,
321 pub last_rate_limit_info: Option<RateLimitInfo>,
323}
324
325impl RetryMetadata {
326 pub fn had_retries(&self) -> bool {
328 self.attempts > 0
329 }
330
331 pub fn first_attempt_success() -> Self {
333 Self::default()
334 }
335
336 pub fn record_retry(
338 &mut self,
339 wait_duration: Duration,
340 rate_limit_info: Option<RateLimitInfo>,
341 ) {
342 self.attempts += 1;
343 self.total_retry_wait += wait_duration;
344 if rate_limit_info.is_some() {
345 self.last_rate_limit_info = rate_limit_info;
346 }
347 }
348}
349
350pub fn is_rate_limit_status(status: reqwest::StatusCode) -> bool {
352 status == reqwest::StatusCode::TOO_MANY_REQUESTS
353}
354
355pub fn is_transient_error(status: reqwest::StatusCode) -> bool {
363 if status == reqwest::StatusCode::REQUEST_TIMEOUT {
365 return true;
366 }
367 if status == reqwest::StatusCode::CONFLICT {
369 return true;
370 }
371 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
373 return true;
374 }
375 if status.is_server_error() && status != reqwest::StatusCode::NOT_IMPLEMENTED {
377 return true;
378 }
379 false
380}
381
382pub fn is_transient_send_error(err: &reqwest::Error) -> bool {
398 err.is_connect() || err.is_timeout() || err.is_request()
399}
400
401pub fn send_error_message(err: &reqwest::Error, attempts: u32) -> String {
404 if attempts > 0 {
405 format!("Failed to send request: {err} (after {attempts} retries)")
406 } else {
407 format!("Failed to send request: {err}")
408 }
409}
410
411pub fn is_transient_error_message(message: &str) -> bool {
416 if crate::user_facing_error::is_usage_limit_message(message) {
422 return false;
423 }
424
425 let msg = message.trim().to_ascii_lowercase();
426
427 if msg.contains("provider stream stall") {
433 return true;
434 }
435
436 [
437 "server_error",
438 "internal server error",
439 "overloaded",
440 "overloaded_error",
441 "rate limit",
442 "too many requests",
443 "request timeout",
444 "timed out",
445 "service unavailable",
446 "bad gateway",
447 "gateway timeout",
448 "temporarily unavailable",
449 ]
450 .iter()
451 .any(|needle| msg.contains(needle))
452}
453
454pub fn is_transient_stream_error(error: &crate::driver_registry::LlmStreamError) -> bool {
460 if let Some(code) = error.code.as_deref()
461 && let Some(kind) = crate::error::LlmErrorKind::from_provider_code(code)
462 {
463 return matches!(
464 kind,
465 crate::error::LlmErrorKind::RateLimited | crate::error::LlmErrorKind::Unavailable
466 );
467 }
468
469 if let Some(status) = error
470 .status
471 .and_then(|status| reqwest::StatusCode::from_u16(status).ok())
472 {
473 return is_transient_error(status);
474 }
475
476 is_transient_error_message(&error.message)
477}
478
479pub enum SendOutcome {
489 Send(reqwest::Error),
492 Fatal(AgentLoopError),
495}
496
497pub enum RetryDecision {
500 Retry {
503 wait: Duration,
504 rate_limit_info: Option<RateLimitInfo>,
505 },
506 RetryNow,
511 Terminal(AgentLoopError),
513}
514
515pub async fn retry_request<S, SFut, C, CFut, E>(
535 config: &LlmRetryConfig,
536 driver_name: &str,
537 mut send: S,
538 mut classify: C,
539 send_error: E,
540) -> Result<(reqwest::Response, RetryMetadata), AgentLoopError>
541where
542 S: FnMut() -> SFut,
543 SFut: Future<Output = Result<reqwest::Response, SendOutcome>>,
544 C: FnMut(reqwest::Response, u32, bool) -> CFut,
545 CFut: Future<Output = RetryDecision>,
546 E: Fn(&reqwest::Error, u32) -> AgentLoopError,
547{
548 let mut retry_metadata = RetryMetadata::default();
549
550 let response = loop {
551 let response = match send().await {
552 Ok(response) => response,
553 Err(SendOutcome::Fatal(err)) => return Err(err),
554 Err(SendOutcome::Send(e)) => {
555 if is_transient_send_error(&e) && retry_metadata.attempts < config.max_retries {
560 let wait_duration = config.calculate_backoff(retry_metadata.attempts);
561 tracing::warn!(
562 error = %e,
563 driver = driver_name,
564 attempt = retry_metadata.attempts + 1,
565 max_retries = config.max_retries,
566 wait_secs = wait_duration.as_secs_f64(),
567 "transient connection error sending request, retrying"
568 );
569 retry_metadata.record_retry(wait_duration, None);
570 tokio::time::sleep(wait_duration).await;
571 continue;
572 }
573 return Err(send_error(&e, retry_metadata.attempts));
574 }
575 };
576
577 let status = response.status();
578 if status.is_success() {
579 break response;
580 }
581
582 let can_retry = is_transient_error(status) && retry_metadata.attempts < config.max_retries;
583 match classify(response, retry_metadata.attempts, can_retry).await {
584 RetryDecision::Retry {
585 wait,
586 rate_limit_info,
587 } => {
588 tracing::warn!(
589 status = %status,
590 driver = driver_name,
591 attempt = retry_metadata.attempts + 1,
592 max_retries = config.max_retries,
593 wait_secs = wait.as_secs_f64(),
594 "rate limit or transient error, retrying"
595 );
596 retry_metadata.record_retry(wait, rate_limit_info);
597 tokio::time::sleep(wait).await;
598 continue;
599 }
600 RetryDecision::RetryNow => continue,
601 RetryDecision::Terminal(err) => return Err(err),
602 }
603 };
604
605 if retry_metadata.had_retries() {
606 tracing::info!(
607 driver = driver_name,
608 attempts = retry_metadata.attempts,
609 total_wait_secs = retry_metadata.total_retry_wait.as_secs_f64(),
610 "request succeeded after retries"
611 );
612 }
613
614 Ok((response, retry_metadata))
615}
616
617#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn test_default_config_matches_official_sdks() {
627 let config = LlmRetryConfig::default();
629 assert_eq!(config.max_retries, 2); assert_eq!(config.initial_backoff, Duration::from_secs(1));
631 assert_eq!(config.max_backoff, Duration::from_secs(60));
632 assert_eq!(config.backoff_multiplier, 2.0);
633 assert!((config.jitter_factor - 0.25).abs() < 0.001); }
635
636 #[test]
637 fn test_calculate_backoff_exponential() {
638 let config = LlmRetryConfig {
639 initial_backoff: Duration::from_secs(1),
640 max_backoff: Duration::from_secs(60),
641 backoff_multiplier: 2.0,
642 jitter_factor: 0.0, ..Default::default()
644 };
645
646 assert_eq!(config.calculate_backoff(0), Duration::from_secs(1));
648 assert_eq!(config.calculate_backoff(1), Duration::from_secs(2));
650 assert_eq!(config.calculate_backoff(2), Duration::from_secs(4));
652 assert_eq!(config.calculate_backoff(3), Duration::from_secs(8));
654 }
655
656 #[test]
657 fn test_calculate_backoff_capped() {
658 let config = LlmRetryConfig {
659 initial_backoff: Duration::from_secs(10),
660 max_backoff: Duration::from_secs(30),
661 backoff_multiplier: 2.0,
662 jitter_factor: 0.0,
663 ..Default::default()
664 };
665
666 assert_eq!(config.calculate_backoff(0), Duration::from_secs(10));
668 assert_eq!(config.calculate_backoff(1), Duration::from_secs(20));
670 assert_eq!(config.calculate_backoff(2), Duration::from_secs(30));
672 assert_eq!(config.calculate_backoff(3), Duration::from_secs(30));
674 }
675
676 #[test]
679 fn test_backoff_jitter_is_randomized() {
680 let config = LlmRetryConfig {
681 initial_backoff: Duration::from_secs(10),
682 max_backoff: Duration::from_secs(60),
683 backoff_multiplier: 2.0,
684 jitter_factor: 0.25,
685 ..Default::default()
686 };
687 let samples: std::collections::HashSet<u128> = (0..20)
688 .map(|_| config.calculate_backoff(1).as_nanos())
689 .collect();
690 assert!(
691 samples.len() > 1,
692 "jittered backoff should vary across calls, got {} distinct value(s)",
693 samples.len()
694 );
695 for _ in 0..50 {
697 let secs = config.calculate_backoff(1).as_secs_f64();
698 assert!(
699 (15.0..=25.0).contains(&secs),
700 "backoff {secs}s out of range"
701 );
702 }
703 }
704
705 #[test]
706 fn test_parse_duration_string() {
707 assert_eq!(parse_duration_string("1s"), Some(1));
708 assert_eq!(parse_duration_string("30s"), Some(30));
709 assert_eq!(parse_duration_string("1m"), Some(60));
710 assert_eq!(parse_duration_string("6m0s"), Some(360));
711 assert_eq!(parse_duration_string("1h"), Some(3600));
712 assert_eq!(parse_duration_string("1h30m"), Some(5400));
713 assert_eq!(parse_duration_string("1h30m45s"), Some(5445));
714 assert_eq!(parse_duration_string(""), None);
715 assert_eq!(parse_duration_string("invalid"), None);
716 }
717
718 #[test]
719 fn test_rate_limit_info_recommended_wait_with_retry_after() {
720 let config = LlmRetryConfig::default();
721 let info = RateLimitInfo {
722 retry_after_secs: Some(10),
723 ..Default::default()
724 };
725
726 assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(10));
728 assert_eq!(info.recommended_wait(&config, 5), Duration::from_secs(10));
729 }
730
731 #[test]
732 fn test_rate_limit_info_recommended_wait_capped_at_60s() {
733 let config = LlmRetryConfig {
735 jitter_factor: 0.0, ..Default::default()
737 };
738 let info = RateLimitInfo {
739 retry_after_secs: Some(120), ..Default::default()
741 };
742
743 assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
745 }
746
747 #[test]
748 fn test_rate_limit_info_recommended_wait_fallback() {
749 let config = LlmRetryConfig {
750 initial_backoff: Duration::from_secs(1),
751 backoff_multiplier: 2.0,
752 jitter_factor: 0.0,
753 ..Default::default()
754 };
755 let info = RateLimitInfo::default(); assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
759 assert_eq!(info.recommended_wait(&config, 1), Duration::from_secs(2));
760 }
761
762 #[test]
763 fn test_retry_metadata_record() {
764 let mut meta = RetryMetadata::default();
765 assert!(!meta.had_retries());
766 assert_eq!(meta.attempts, 0);
767
768 meta.record_retry(Duration::from_secs(1), None);
769 assert!(meta.had_retries());
770 assert_eq!(meta.attempts, 1);
771 assert_eq!(meta.total_retry_wait, Duration::from_secs(1));
772
773 meta.record_retry(Duration::from_secs(2), None);
774 assert_eq!(meta.attempts, 2);
775 assert_eq!(meta.total_retry_wait, Duration::from_secs(3));
776 }
777
778 #[test]
779 fn test_is_transient_error_matches_official_sdks() {
780 assert!(is_transient_error(reqwest::StatusCode::REQUEST_TIMEOUT)); assert!(is_transient_error(reqwest::StatusCode::CONFLICT)); assert!(is_transient_error(reqwest::StatusCode::TOO_MANY_REQUESTS)); assert!(is_transient_error(
785 reqwest::StatusCode::INTERNAL_SERVER_ERROR
786 )); assert!(is_transient_error(reqwest::StatusCode::BAD_GATEWAY)); assert!(is_transient_error(reqwest::StatusCode::SERVICE_UNAVAILABLE)); assert!(is_transient_error(reqwest::StatusCode::GATEWAY_TIMEOUT)); assert!(!is_transient_error(reqwest::StatusCode::OK));
793 assert!(!is_transient_error(reqwest::StatusCode::BAD_REQUEST)); assert!(!is_transient_error(reqwest::StatusCode::UNAUTHORIZED)); assert!(!is_transient_error(reqwest::StatusCode::FORBIDDEN)); assert!(!is_transient_error(reqwest::StatusCode::NOT_FOUND)); assert!(!is_transient_error(reqwest::StatusCode::NOT_IMPLEMENTED)); }
799
800 #[tokio::test]
804 async fn test_is_transient_send_error_on_connection_refused() {
805 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
808 let addr = listener.local_addr().unwrap();
809 drop(listener);
810
811 let err = reqwest::Client::new()
812 .get(format!("http://{addr}/"))
813 .send()
814 .await
815 .expect_err("request to a closed port should fail");
816
817 assert!(
818 is_transient_send_error(&err),
819 "connection-refused send error should be transient: {err:?}"
820 );
821 }
822
823 #[test]
824 fn test_is_transient_error_message_detects_provider_server_errors() {
825 assert!(is_transient_error_message(
826 "server_error: An error occurred while processing your request."
827 ));
828 assert!(is_transient_error_message("Rate limit exceeded"));
829 assert!(is_transient_error_message(
830 "Service temporarily unavailable"
831 ));
832 }
833
834 #[test]
835 fn test_is_transient_error_message_rejects_non_retryable_messages() {
836 assert!(!is_transient_error_message(
837 "invalid_request_error: bad tool schema"
838 ));
839 assert!(!is_transient_error_message("Model not available: gpt-99"));
840 }
841
842 #[test]
843 fn structured_stream_error_prefers_code_and_status_over_message() {
844 use crate::driver_registry::LlmStreamError;
845
846 assert!(is_transient_stream_error(&LlmStreamError::provider(
847 Some("processing_error"),
848 None,
849 "An error occurred while processing your request.",
850 )));
851 assert!(is_transient_stream_error(&LlmStreamError::provider(
852 None::<String>,
853 Some(503),
854 "opaque failure",
855 )));
856 assert!(!is_transient_stream_error(&LlmStreamError::provider(
857 Some("invalid_request_error"),
858 Some(503),
859 "server unavailable",
860 )));
861 assert!(!is_transient_stream_error(&LlmStreamError::provider(
862 Some("insufficient_quota"),
863 Some(429),
864 "rate limit",
865 )));
866 }
867
868 #[test]
869 fn test_provider_stream_stall_is_transient() {
870 assert!(is_transient_error_message(
873 "provider stream stall: no tokens for 120s"
874 ));
875 use crate::driver_registry::LlmStreamError;
878 assert!(is_transient_stream_error(&LlmStreamError::new(
879 "provider stream stall: no tokens for 120s"
880 )));
881 }
882
883 #[test]
884 fn test_is_transient_error_message_treats_usage_limit_as_non_transient() {
885 assert!(!is_transient_error_message(
889 "Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":1783767823}}"
890 ));
891 }
892
893 #[test]
894 fn test_max_retry_after_constant() {
895 assert_eq!(MAX_RETRY_AFTER_SECS, 60);
897 }
898
899 fn fake_response(status: u16, body: &str) -> reqwest::Response {
905 let http_response = http::Response::builder()
906 .status(status)
907 .body(body.to_string())
908 .unwrap();
909 reqwest::Response::from(http_response)
910 }
911
912 fn fast_config(max_retries: u32) -> LlmRetryConfig {
914 LlmRetryConfig {
915 max_retries,
916 initial_backoff: Duration::from_millis(0),
917 max_backoff: Duration::from_millis(0),
918 backoff_multiplier: 1.0,
919 jitter_factor: 0.0,
920 }
921 }
922
923 #[tokio::test]
924 async fn test_retry_request_success_first_try() {
925 let config = fast_config(2);
926 let (resp, meta) = retry_request(
927 &config,
928 "TestDriver",
929 || async { Ok(fake_response(200, "ok")) },
930 |_resp, _attempt, _can_retry| async {
931 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
932 },
933 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
934 )
935 .await
936 .expect("should succeed");
937 assert!(resp.status().is_success());
938 assert_eq!(meta.attempts, 0);
939 assert!(!meta.had_retries());
940 }
941
942 #[tokio::test]
943 async fn test_retry_request_retries_then_succeeds() {
944 let config = fast_config(3);
945 let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
946 let calls_send = calls.clone();
947 let (resp, meta) = retry_request(
948 &config,
949 "TestDriver",
950 move || {
951 let calls = calls_send.clone();
952 async move {
953 let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
954 if n < 2 {
956 Ok(fake_response(429, "rate limited"))
957 } else {
958 Ok(fake_response(200, "ok"))
959 }
960 }
961 },
962 |_resp, _attempt, can_retry| async move {
963 assert!(can_retry, "429 within budget should be retryable");
964 RetryDecision::Retry {
965 wait: Duration::from_millis(0),
966 rate_limit_info: None,
967 }
968 },
969 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
970 )
971 .await
972 .expect("should eventually succeed");
973 assert!(resp.status().is_success());
974 assert_eq!(meta.attempts, 2);
975 }
976
977 #[tokio::test]
978 async fn test_retry_request_terminal_decision_propagates() {
979 let config = fast_config(2);
980 let result = retry_request(
981 &config,
982 "TestDriver",
983 || async { Ok(fake_response(400, "bad request")) },
984 |_resp, _attempt, _can_retry| async {
985 RetryDecision::Terminal(AgentLoopError::llm("classified terminal"))
986 },
987 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
988 )
989 .await;
990 let err = result.expect_err("terminal decision should error");
991 assert!(err.to_string().contains("classified terminal"));
992 }
993
994 #[tokio::test]
995 async fn test_retry_request_retry_now_does_not_count_attempt() {
996 let config = fast_config(2);
997 let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
998 let calls_send = calls.clone();
999 let (resp, meta) = retry_request(
1000 &config,
1001 "TestDriver",
1002 move || {
1003 let calls = calls_send.clone();
1004 async move {
1005 let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1006 if n == 0 {
1007 Ok(fake_response(400, "max_tokens too large"))
1008 } else {
1009 Ok(fake_response(200, "ok"))
1010 }
1011 }
1012 },
1013 {
1014 let mut used_fallback = false;
1015 move |_resp, _attempt, _can_retry| {
1016 let do_fallback = !used_fallback;
1017 used_fallback = true;
1018 async move {
1019 if do_fallback {
1020 RetryDecision::RetryNow
1021 } else {
1022 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1023 }
1024 }
1025 }
1026 },
1027 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1028 )
1029 .await
1030 .expect("RetryNow then success");
1031 assert!(resp.status().is_success());
1032 assert_eq!(meta.attempts, 0);
1034 }
1035
1036 #[tokio::test]
1037 async fn test_retry_request_send_error_exhausts() {
1038 let config = fast_config(1);
1041
1042 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1044 let addr = listener.local_addr().unwrap();
1045 drop(listener);
1046 let make_err = || async {
1047 reqwest::Client::new()
1048 .get(format!("http://{addr}/"))
1049 .send()
1050 .await
1051 .expect_err("closed port")
1052 };
1053
1054 let result = retry_request(
1055 &config,
1056 "TestDriver",
1057 move || async move { Err(SendOutcome::Send(make_err().await)) },
1058 |_resp, _attempt, _can_retry| async {
1059 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1060 },
1061 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1062 )
1063 .await;
1064 let err = result.expect_err("send errors should exhaust to terminal");
1065 assert!(err.to_string().contains("after 1 retries"), "got: {err}");
1067 }
1068
1069 #[tokio::test]
1070 async fn test_retry_request_fatal_send_propagates_immediately() {
1071 let config = fast_config(3);
1072 let result = retry_request(
1073 &config,
1074 "TestDriver",
1075 || async { Err(SendOutcome::Fatal(AgentLoopError::llm("auth failed"))) },
1076 |_resp, _attempt, _can_retry| async {
1077 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1078 },
1079 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1080 )
1081 .await;
1082 let err = result.expect_err("fatal send should propagate");
1083 assert!(err.to_string().contains("auth failed"));
1084 }
1085}