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 [
428 "server_error",
429 "internal server error",
430 "overloaded",
431 "overloaded_error",
432 "rate limit",
433 "too many requests",
434 "request timeout",
435 "timed out",
436 "service unavailable",
437 "bad gateway",
438 "gateway timeout",
439 "temporarily unavailable",
440 ]
441 .iter()
442 .any(|needle| msg.contains(needle))
443}
444
445pub fn is_transient_stream_error(error: &crate::driver_registry::LlmStreamError) -> bool {
451 if let Some(code) = error.code.as_deref()
452 && let Some(kind) = crate::error::LlmErrorKind::from_provider_code(code)
453 {
454 return matches!(
455 kind,
456 crate::error::LlmErrorKind::RateLimited | crate::error::LlmErrorKind::Unavailable
457 );
458 }
459
460 if let Some(status) = error
461 .status
462 .and_then(|status| reqwest::StatusCode::from_u16(status).ok())
463 {
464 return is_transient_error(status);
465 }
466
467 is_transient_error_message(&error.message)
468}
469
470pub enum SendOutcome {
480 Send(reqwest::Error),
483 Fatal(AgentLoopError),
486}
487
488pub enum RetryDecision {
491 Retry {
494 wait: Duration,
495 rate_limit_info: Option<RateLimitInfo>,
496 },
497 RetryNow,
502 Terminal(AgentLoopError),
504}
505
506pub async fn retry_request<S, SFut, C, CFut, E>(
526 config: &LlmRetryConfig,
527 driver_name: &str,
528 mut send: S,
529 mut classify: C,
530 send_error: E,
531) -> Result<(reqwest::Response, RetryMetadata), AgentLoopError>
532where
533 S: FnMut() -> SFut,
534 SFut: Future<Output = Result<reqwest::Response, SendOutcome>>,
535 C: FnMut(reqwest::Response, u32, bool) -> CFut,
536 CFut: Future<Output = RetryDecision>,
537 E: Fn(&reqwest::Error, u32) -> AgentLoopError,
538{
539 let mut retry_metadata = RetryMetadata::default();
540
541 let response = loop {
542 let response = match send().await {
543 Ok(response) => response,
544 Err(SendOutcome::Fatal(err)) => return Err(err),
545 Err(SendOutcome::Send(e)) => {
546 if is_transient_send_error(&e) && retry_metadata.attempts < config.max_retries {
551 let wait_duration = config.calculate_backoff(retry_metadata.attempts);
552 tracing::warn!(
553 error = %e,
554 driver = driver_name,
555 attempt = retry_metadata.attempts + 1,
556 max_retries = config.max_retries,
557 wait_secs = wait_duration.as_secs_f64(),
558 "transient connection error sending request, retrying"
559 );
560 retry_metadata.record_retry(wait_duration, None);
561 tokio::time::sleep(wait_duration).await;
562 continue;
563 }
564 return Err(send_error(&e, retry_metadata.attempts));
565 }
566 };
567
568 let status = response.status();
569 if status.is_success() {
570 break response;
571 }
572
573 let can_retry = is_transient_error(status) && retry_metadata.attempts < config.max_retries;
574 match classify(response, retry_metadata.attempts, can_retry).await {
575 RetryDecision::Retry {
576 wait,
577 rate_limit_info,
578 } => {
579 tracing::warn!(
580 status = %status,
581 driver = driver_name,
582 attempt = retry_metadata.attempts + 1,
583 max_retries = config.max_retries,
584 wait_secs = wait.as_secs_f64(),
585 "rate limit or transient error, retrying"
586 );
587 retry_metadata.record_retry(wait, rate_limit_info);
588 tokio::time::sleep(wait).await;
589 continue;
590 }
591 RetryDecision::RetryNow => continue,
592 RetryDecision::Terminal(err) => return Err(err),
593 }
594 };
595
596 if retry_metadata.had_retries() {
597 tracing::info!(
598 driver = driver_name,
599 attempts = retry_metadata.attempts,
600 total_wait_secs = retry_metadata.total_retry_wait.as_secs_f64(),
601 "request succeeded after retries"
602 );
603 }
604
605 Ok((response, retry_metadata))
606}
607
608#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn test_default_config_matches_official_sdks() {
618 let config = LlmRetryConfig::default();
620 assert_eq!(config.max_retries, 2); assert_eq!(config.initial_backoff, Duration::from_secs(1));
622 assert_eq!(config.max_backoff, Duration::from_secs(60));
623 assert_eq!(config.backoff_multiplier, 2.0);
624 assert!((config.jitter_factor - 0.25).abs() < 0.001); }
626
627 #[test]
628 fn test_calculate_backoff_exponential() {
629 let config = LlmRetryConfig {
630 initial_backoff: Duration::from_secs(1),
631 max_backoff: Duration::from_secs(60),
632 backoff_multiplier: 2.0,
633 jitter_factor: 0.0, ..Default::default()
635 };
636
637 assert_eq!(config.calculate_backoff(0), Duration::from_secs(1));
639 assert_eq!(config.calculate_backoff(1), Duration::from_secs(2));
641 assert_eq!(config.calculate_backoff(2), Duration::from_secs(4));
643 assert_eq!(config.calculate_backoff(3), Duration::from_secs(8));
645 }
646
647 #[test]
648 fn test_calculate_backoff_capped() {
649 let config = LlmRetryConfig {
650 initial_backoff: Duration::from_secs(10),
651 max_backoff: Duration::from_secs(30),
652 backoff_multiplier: 2.0,
653 jitter_factor: 0.0,
654 ..Default::default()
655 };
656
657 assert_eq!(config.calculate_backoff(0), Duration::from_secs(10));
659 assert_eq!(config.calculate_backoff(1), Duration::from_secs(20));
661 assert_eq!(config.calculate_backoff(2), Duration::from_secs(30));
663 assert_eq!(config.calculate_backoff(3), Duration::from_secs(30));
665 }
666
667 #[test]
670 fn test_backoff_jitter_is_randomized() {
671 let config = LlmRetryConfig {
672 initial_backoff: Duration::from_secs(10),
673 max_backoff: Duration::from_secs(60),
674 backoff_multiplier: 2.0,
675 jitter_factor: 0.25,
676 ..Default::default()
677 };
678 let samples: std::collections::HashSet<u128> = (0..20)
679 .map(|_| config.calculate_backoff(1).as_nanos())
680 .collect();
681 assert!(
682 samples.len() > 1,
683 "jittered backoff should vary across calls, got {} distinct value(s)",
684 samples.len()
685 );
686 for _ in 0..50 {
688 let secs = config.calculate_backoff(1).as_secs_f64();
689 assert!(
690 (15.0..=25.0).contains(&secs),
691 "backoff {secs}s out of range"
692 );
693 }
694 }
695
696 #[test]
697 fn test_parse_duration_string() {
698 assert_eq!(parse_duration_string("1s"), Some(1));
699 assert_eq!(parse_duration_string("30s"), Some(30));
700 assert_eq!(parse_duration_string("1m"), Some(60));
701 assert_eq!(parse_duration_string("6m0s"), Some(360));
702 assert_eq!(parse_duration_string("1h"), Some(3600));
703 assert_eq!(parse_duration_string("1h30m"), Some(5400));
704 assert_eq!(parse_duration_string("1h30m45s"), Some(5445));
705 assert_eq!(parse_duration_string(""), None);
706 assert_eq!(parse_duration_string("invalid"), None);
707 }
708
709 #[test]
710 fn test_rate_limit_info_recommended_wait_with_retry_after() {
711 let config = LlmRetryConfig::default();
712 let info = RateLimitInfo {
713 retry_after_secs: Some(10),
714 ..Default::default()
715 };
716
717 assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(10));
719 assert_eq!(info.recommended_wait(&config, 5), Duration::from_secs(10));
720 }
721
722 #[test]
723 fn test_rate_limit_info_recommended_wait_capped_at_60s() {
724 let config = LlmRetryConfig {
726 jitter_factor: 0.0, ..Default::default()
728 };
729 let info = RateLimitInfo {
730 retry_after_secs: Some(120), ..Default::default()
732 };
733
734 assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
736 }
737
738 #[test]
739 fn test_rate_limit_info_recommended_wait_fallback() {
740 let config = LlmRetryConfig {
741 initial_backoff: Duration::from_secs(1),
742 backoff_multiplier: 2.0,
743 jitter_factor: 0.0,
744 ..Default::default()
745 };
746 let info = RateLimitInfo::default(); assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
750 assert_eq!(info.recommended_wait(&config, 1), Duration::from_secs(2));
751 }
752
753 #[test]
754 fn test_retry_metadata_record() {
755 let mut meta = RetryMetadata::default();
756 assert!(!meta.had_retries());
757 assert_eq!(meta.attempts, 0);
758
759 meta.record_retry(Duration::from_secs(1), None);
760 assert!(meta.had_retries());
761 assert_eq!(meta.attempts, 1);
762 assert_eq!(meta.total_retry_wait, Duration::from_secs(1));
763
764 meta.record_retry(Duration::from_secs(2), None);
765 assert_eq!(meta.attempts, 2);
766 assert_eq!(meta.total_retry_wait, Duration::from_secs(3));
767 }
768
769 #[test]
770 fn test_is_transient_error_matches_official_sdks() {
771 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(
776 reqwest::StatusCode::INTERNAL_SERVER_ERROR
777 )); 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));
784 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)); }
790
791 #[tokio::test]
795 async fn test_is_transient_send_error_on_connection_refused() {
796 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
799 let addr = listener.local_addr().unwrap();
800 drop(listener);
801
802 let err = reqwest::Client::new()
803 .get(format!("http://{addr}/"))
804 .send()
805 .await
806 .expect_err("request to a closed port should fail");
807
808 assert!(
809 is_transient_send_error(&err),
810 "connection-refused send error should be transient: {err:?}"
811 );
812 }
813
814 #[test]
815 fn test_is_transient_error_message_detects_provider_server_errors() {
816 assert!(is_transient_error_message(
817 "server_error: An error occurred while processing your request."
818 ));
819 assert!(is_transient_error_message("Rate limit exceeded"));
820 assert!(is_transient_error_message(
821 "Service temporarily unavailable"
822 ));
823 }
824
825 #[test]
826 fn test_is_transient_error_message_rejects_non_retryable_messages() {
827 assert!(!is_transient_error_message(
828 "invalid_request_error: bad tool schema"
829 ));
830 assert!(!is_transient_error_message("Model not available: gpt-99"));
831 }
832
833 #[test]
834 fn structured_stream_error_prefers_code_and_status_over_message() {
835 use crate::driver_registry::LlmStreamError;
836
837 assert!(is_transient_stream_error(&LlmStreamError::provider(
838 Some("processing_error"),
839 None,
840 "An error occurred while processing your request.",
841 )));
842 assert!(is_transient_stream_error(&LlmStreamError::provider(
843 None::<String>,
844 Some(503),
845 "opaque failure",
846 )));
847 assert!(!is_transient_stream_error(&LlmStreamError::provider(
848 Some("invalid_request_error"),
849 Some(503),
850 "server unavailable",
851 )));
852 assert!(!is_transient_stream_error(&LlmStreamError::provider(
853 Some("insufficient_quota"),
854 Some(429),
855 "rate limit",
856 )));
857 }
858
859 #[test]
860 fn test_is_transient_error_message_treats_usage_limit_as_non_transient() {
861 assert!(!is_transient_error_message(
865 "Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":1783767823}}"
866 ));
867 }
868
869 #[test]
870 fn test_max_retry_after_constant() {
871 assert_eq!(MAX_RETRY_AFTER_SECS, 60);
873 }
874
875 fn fake_response(status: u16, body: &str) -> reqwest::Response {
881 let http_response = http::Response::builder()
882 .status(status)
883 .body(body.to_string())
884 .unwrap();
885 reqwest::Response::from(http_response)
886 }
887
888 fn fast_config(max_retries: u32) -> LlmRetryConfig {
890 LlmRetryConfig {
891 max_retries,
892 initial_backoff: Duration::from_millis(0),
893 max_backoff: Duration::from_millis(0),
894 backoff_multiplier: 1.0,
895 jitter_factor: 0.0,
896 }
897 }
898
899 #[tokio::test]
900 async fn test_retry_request_success_first_try() {
901 let config = fast_config(2);
902 let (resp, meta) = retry_request(
903 &config,
904 "TestDriver",
905 || async { Ok(fake_response(200, "ok")) },
906 |_resp, _attempt, _can_retry| async {
907 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
908 },
909 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
910 )
911 .await
912 .expect("should succeed");
913 assert!(resp.status().is_success());
914 assert_eq!(meta.attempts, 0);
915 assert!(!meta.had_retries());
916 }
917
918 #[tokio::test]
919 async fn test_retry_request_retries_then_succeeds() {
920 let config = fast_config(3);
921 let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
922 let calls_send = calls.clone();
923 let (resp, meta) = retry_request(
924 &config,
925 "TestDriver",
926 move || {
927 let calls = calls_send.clone();
928 async move {
929 let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
930 if n < 2 {
932 Ok(fake_response(429, "rate limited"))
933 } else {
934 Ok(fake_response(200, "ok"))
935 }
936 }
937 },
938 |_resp, _attempt, can_retry| async move {
939 assert!(can_retry, "429 within budget should be retryable");
940 RetryDecision::Retry {
941 wait: Duration::from_millis(0),
942 rate_limit_info: None,
943 }
944 },
945 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
946 )
947 .await
948 .expect("should eventually succeed");
949 assert!(resp.status().is_success());
950 assert_eq!(meta.attempts, 2);
951 }
952
953 #[tokio::test]
954 async fn test_retry_request_terminal_decision_propagates() {
955 let config = fast_config(2);
956 let result = retry_request(
957 &config,
958 "TestDriver",
959 || async { Ok(fake_response(400, "bad request")) },
960 |_resp, _attempt, _can_retry| async {
961 RetryDecision::Terminal(AgentLoopError::llm("classified terminal"))
962 },
963 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
964 )
965 .await;
966 let err = result.expect_err("terminal decision should error");
967 assert!(err.to_string().contains("classified terminal"));
968 }
969
970 #[tokio::test]
971 async fn test_retry_request_retry_now_does_not_count_attempt() {
972 let config = fast_config(2);
973 let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
974 let calls_send = calls.clone();
975 let (resp, meta) = retry_request(
976 &config,
977 "TestDriver",
978 move || {
979 let calls = calls_send.clone();
980 async move {
981 let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
982 if n == 0 {
983 Ok(fake_response(400, "max_tokens too large"))
984 } else {
985 Ok(fake_response(200, "ok"))
986 }
987 }
988 },
989 {
990 let mut used_fallback = false;
991 move |_resp, _attempt, _can_retry| {
992 let do_fallback = !used_fallback;
993 used_fallback = true;
994 async move {
995 if do_fallback {
996 RetryDecision::RetryNow
997 } else {
998 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
999 }
1000 }
1001 }
1002 },
1003 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1004 )
1005 .await
1006 .expect("RetryNow then success");
1007 assert!(resp.status().is_success());
1008 assert_eq!(meta.attempts, 0);
1010 }
1011
1012 #[tokio::test]
1013 async fn test_retry_request_send_error_exhausts() {
1014 let config = fast_config(1);
1017
1018 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1020 let addr = listener.local_addr().unwrap();
1021 drop(listener);
1022 let make_err = || async {
1023 reqwest::Client::new()
1024 .get(format!("http://{addr}/"))
1025 .send()
1026 .await
1027 .expect_err("closed port")
1028 };
1029
1030 let result = retry_request(
1031 &config,
1032 "TestDriver",
1033 move || async move { Err(SendOutcome::Send(make_err().await)) },
1034 |_resp, _attempt, _can_retry| async {
1035 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1036 },
1037 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1038 )
1039 .await;
1040 let err = result.expect_err("send errors should exhaust to terminal");
1041 assert!(err.to_string().contains("after 1 retries"), "got: {err}");
1043 }
1044
1045 #[tokio::test]
1046 async fn test_retry_request_fatal_send_propagates_immediately() {
1047 let config = fast_config(3);
1048 let result = retry_request(
1049 &config,
1050 "TestDriver",
1051 || async { Err(SendOutcome::Fatal(AgentLoopError::llm("auth failed"))) },
1052 |_resp, _attempt, _can_retry| async {
1053 RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1054 },
1055 |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1056 )
1057 .await;
1058 let err = result.expect_err("fatal send should propagate");
1059 assert!(err.to_string().contains("auth failed"));
1060 }
1061}