Skip to main content

everruns_provider/
llm_retry.rs

1// LLM Rate Limit Retry Logic
2//
3// Provider-specific retry handling for transient API errors (429, 408, 409, 5xx).
4// Separate from durable execution RetryPolicy - this handles transient API errors.
5//
6// Aligns with official SDK behavior:
7// - Anthropic SDK: https://github.com/anthropics/anthropic-sdk-python
8// - OpenAI SDK: https://github.com/openai/openai-python
9//
10// Provider-specific headers:
11// - Anthropic: retry-after, retry-after-ms, anthropic-ratelimit-*
12// - OpenAI: retry-after, retry-after-ms, x-ratelimit-*
13//
14// Design: exponential backoff with 25% jitter, respecting provider retry-after hints.
15// Defaults match official SDKs: 2 retries, 1s initial, 60s max, 2x multiplier.
16
17use crate::error::AgentLoopError;
18use rand::RngExt;
19use std::future::Future;
20use std::time::Duration;
21
22/// Maximum retry-after value to honor (seconds).
23/// Matches official SDK behavior - if server says wait longer, use backoff instead.
24const MAX_RETRY_AFTER_SECS: u64 = 60;
25
26/// Configuration for LLM rate limit retry behavior.
27///
28/// Defaults match official Anthropic/OpenAI SDK behavior:
29/// - max_retries: 2
30/// - initial_backoff: 1 second
31/// - max_backoff: 60 seconds
32/// - backoff_multiplier: 2.0
33/// - jitter_factor: 0.25 (±25%)
34#[derive(Debug, Clone)]
35pub struct LlmRetryConfig {
36    /// Maximum number of retry attempts (0 = no retries)
37    pub max_retries: u32,
38    /// Initial backoff duration (before exponential increase)
39    pub initial_backoff: Duration,
40    /// Maximum backoff duration (cap for exponential growth)
41    pub max_backoff: Duration,
42    /// Backoff multiplier (typically 2.0 for exponential)
43    pub backoff_multiplier: f64,
44    /// Jitter factor (0.0-1.0, adds randomness to avoid thundering herd)
45    /// Official SDKs use 0.25 (±25%)
46    pub jitter_factor: f64,
47}
48
49impl Default for LlmRetryConfig {
50    fn default() -> Self {
51        // Matches official Anthropic/OpenAI SDK defaults
52        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    /// Create a config with no retries (fail immediately on rate limit)
64    pub fn no_retry() -> Self {
65        Self {
66            max_retries: 0,
67            ..Default::default()
68        }
69    }
70
71    /// Create a config with aggressive retry settings (more retries, longer waits)
72    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    /// Calculate backoff duration for a given attempt number (0-indexed)
83    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        // Add jitter (±jitter_factor around the base)
89        // Official SDKs use: sleep_seconds * (1 - 0.25 * random()) where random is 0-1
90        // This gives range [0.75, 1.0] * base
91        let jitter = if self.jitter_factor > 0.0 {
92            let jitter_range = capped_backoff * self.jitter_factor;
93            // EVE-635: real RNG so concurrent clients hitting the same attempt
94            // number (e.g. a shared 429/503) do not retry in lockstep
95            // (thundering herd). Range [-1, 1).
96            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/// Rate limit information extracted from provider response headers
107#[derive(Debug, Clone, Default)]
108pub struct RateLimitInfo {
109    /// Retry-After header value (seconds to wait)
110    pub retry_after_secs: Option<u64>,
111    /// Requests remaining before limit
112    pub requests_remaining: Option<u32>,
113    /// Tokens remaining before limit
114    pub tokens_remaining: Option<u32>,
115    /// Time until request limit resets
116    pub requests_reset: Option<String>,
117    /// Time until token limit resets
118    pub tokens_reset: Option<String>,
119    /// Provider-specific limit type that was hit
120    pub limit_type: Option<RateLimitType>,
121}
122
123/// Type of rate limit that was exceeded
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum RateLimitType {
126    /// Requests per minute/hour
127    Requests,
128    /// Input tokens per minute
129    InputTokens,
130    /// Output tokens per minute
131    OutputTokens,
132    /// Total tokens per minute
133    TotalTokens,
134    /// Unknown or unspecified
135    Unknown,
136}
137
138impl RateLimitInfo {
139    /// Get the recommended wait duration, preferring retry-after if available.
140    /// Caps retry-after at MAX_RETRY_AFTER_SECS (60s) like official SDKs.
141    pub fn recommended_wait(&self, config: &LlmRetryConfig, attempt: u32) -> Duration {
142        if let Some(retry_after) = self.retry_after_secs {
143            // Provider told us how long to wait
144            // Cap at 60s like official SDKs - if longer, use backoff instead
145            if retry_after > 0 && retry_after <= MAX_RETRY_AFTER_SECS {
146                return Duration::from_secs(retry_after);
147            }
148        }
149        // Fall back to exponential backoff
150        config.calculate_backoff(attempt)
151    }
152
153    /// Parse rate limit info from Anthropic response headers
154    pub fn from_anthropic_headers(headers: &reqwest::header::HeaderMap) -> Self {
155        let mut info = Self::default();
156
157        // Try non-standard retry-after-ms header first (milliseconds)
158        // Used by some providers for sub-second precision
159        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            // Convert ms to seconds (round up)
164            info.retry_after_secs = Some(ms.div_ceil(1000));
165        }
166
167        // retry-after header (standard, seconds)
168        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        // anthropic-ratelimit-requests-remaining
176        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        // anthropic-ratelimit-tokens-remaining
183        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        // anthropic-ratelimit-requests-reset
190        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        // anthropic-ratelimit-tokens-reset
197        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        // Determine limit type from remaining values
204        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    /// Parse rate limit info from OpenAI-compatible response headers.
214    pub fn from_openai_headers(headers: &reqwest::header::HeaderMap) -> Self {
215        let mut info = Self::default();
216
217        // Try non-standard retry-after-ms header first (milliseconds)
218        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            // Convert ms to seconds (round up)
223            info.retry_after_secs = Some(ms.div_ceil(1000));
224        }
225
226        // retry-after header (standard, seconds)
227        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        // x-ratelimit-remaining-requests
235        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        // x-ratelimit-remaining-tokens
242        if let Some(val) = headers.get("x-ratelimit-remaining-tokens")
243            && let Ok(s) = val.to_str()
244        {
245            // OpenAI sometimes returns -1 for unlimited
246            let val: i64 = s.parse().unwrap_or(-1);
247            if val >= 0 {
248                info.tokens_remaining = Some(val as u32);
249            }
250        }
251
252        // x-ratelimit-reset-requests (e.g., "1s", "6m0s")
253        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            // Try to parse as seconds for retry-after fallback
258            if info.retry_after_secs.is_none() {
259                info.retry_after_secs = parse_duration_string(s);
260            }
261        }
262
263        // x-ratelimit-reset-tokens
264        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        // Determine limit type
271        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
281/// Parse duration strings like "1s", "6m0s", "1h30m"
282fn 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/// Metadata about retry attempts for observability
315#[derive(Debug, Clone, Default)]
316pub struct RetryMetadata {
317    /// Number of retry attempts made (0 = succeeded on first try)
318    pub attempts: u32,
319    /// Total time spent waiting between retries
320    pub total_retry_wait: Duration,
321    /// Rate limit info from the last 429 response (if any)
322    pub last_rate_limit_info: Option<RateLimitInfo>,
323}
324
325impl RetryMetadata {
326    /// Check if any retries were made
327    pub fn had_retries(&self) -> bool {
328        self.attempts > 0
329    }
330
331    /// Create metadata for a successful first attempt
332    pub fn first_attempt_success() -> Self {
333        Self::default()
334    }
335
336    /// Record a retry attempt
337    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
350/// Check if an HTTP status code is a rate limit error (429)
351pub fn is_rate_limit_status(status: reqwest::StatusCode) -> bool {
352    status == reqwest::StatusCode::TOO_MANY_REQUESTS
353}
354
355/// Check if an error is a transient error that should be retried.
356///
357/// Matches official SDK behavior - retries on:
358/// - 408 Request Timeout
359/// - 409 Conflict (lock timeout)
360/// - 429 Too Many Requests (rate limit)
361/// - 5xx Server errors (except 501 Not Implemented)
362pub fn is_transient_error(status: reqwest::StatusCode) -> bool {
363    // 408 Request Timeout
364    if status == reqwest::StatusCode::REQUEST_TIMEOUT {
365        return true;
366    }
367    // 409 Conflict (often lock timeout in APIs)
368    if status == reqwest::StatusCode::CONFLICT {
369        return true;
370    }
371    // 429 Too Many Requests
372    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
373        return true;
374    }
375    // 5xx Server errors (except 501 Not Implemented)
376    if status.is_server_error() && status != reqwest::StatusCode::NOT_IMPLEMENTED {
377        return true;
378    }
379    false
380}
381
382/// Check if a `reqwest` error raised while *sending* a request is a transient
383/// connection-level failure that is safe to retry.
384///
385/// These are errors where the request never produced an HTTP response: a
386/// connect/TLS failure, a connect timeout, or a generic send failure on a
387/// (possibly stale) pooled keep-alive connection — surfaced by reqwest as
388/// `error sending request for url ...`. The official Anthropic/OpenAI SDKs
389/// retry exactly these as `APIConnectionError`. Because the server produced no
390/// response, the request had no effect, so retrying is safe.
391///
392/// This matters since EVE-635 introduced a process-wide shared connection pool:
393/// a keep-alive connection the peer has already closed is only discovered when
394/// the next request tries to reuse it, and that send fails here rather than
395/// returning an HTTP status — so it must be retried alongside the 429/5xx path
396/// (see `is_transient_error`).
397pub fn is_transient_send_error(err: &reqwest::Error) -> bool {
398    err.is_connect() || err.is_timeout() || err.is_request()
399}
400
401/// Build the user-facing message for a request that failed to send, noting how
402/// many retries were exhausted so it mirrors the HTTP-status error path.
403pub 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
411/// Check if an in-band provider error message looks transient and safe to retry.
412///
413/// This complements HTTP-status-based retry detection for streaming APIs that can
414/// emit retryable provider failures inside an otherwise successful event stream.
415pub fn is_transient_error_message(message: &str) -> bool {
416    // A subscription/plan usage limit (e.g. Codex `usage_limit_reached`) surfaces
417    // as a 429 ("too many requests") but does not recover within the retry
418    // window — it resets hours later at `resets_at`. Treating it as transient
419    // would waste retries and suppress the human-readable error message the
420    // reason atom emits for terminal failures, so it is explicitly non-transient.
421    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    // EVE-806: the runtime's stream-liveness watchdog aborts a stream that
428    // produced no tokens within its window with "provider stream stall: no
429    // tokens for Ns". A stall before any output is equivalent to a dropped
430    // connection, so it is transient and safe for the shared bounded retry path
431    // to recover.
432    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
454/// Classify an in-band provider error using structured fields first.
455///
456/// Machine-readable provider codes are authoritative, followed by HTTP status.
457/// Message matching is retained only for legacy drivers that cannot preserve
458/// either field.
459pub 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
479// ============================================================================
480// Generic retry executor
481// ============================================================================
482
483/// Outcome of a failed *send* (no HTTP response was produced).
484///
485/// Lets the [`retry_request`] caller's send closure distinguish a hard error
486/// that must propagate immediately (e.g. an auth-header failure) from a
487/// `reqwest` transport error that should be classified for transient retry.
488pub enum SendOutcome {
489    /// A `reqwest` send error (connection/TLS/timeout). Classified via
490    /// [`is_transient_send_error`] for retry.
491    Send(reqwest::Error),
492    /// A non-transport error that must abort the loop immediately (e.g. an
493    /// `AuthHeaderProvider` failure resolved per attempt).
494    Fatal(AgentLoopError),
495}
496
497/// Decision returned by the per-attempt classifier when a response carries a
498/// non-success HTTP status.
499pub enum RetryDecision {
500    /// Retry after the given wait duration, recording the supplied rate-limit
501    /// info against the retry metadata.
502    Retry {
503        wait: Duration,
504        rate_limit_info: Option<RateLimitInfo>,
505    },
506    /// Retry immediately without counting an attempt or sleeping (e.g.
507    /// Anthropic's one-shot `max_tokens` fallback that mutates the request and
508    /// re-sends). Use sparingly — the classifier is responsible for ensuring
509    /// this cannot loop forever.
510    RetryNow,
511    /// Stop and return this terminal error to the caller.
512    Terminal(AgentLoopError),
513}
514
515/// Run the shared LLM request retry loop.
516///
517/// Reproduces the loop every native streaming driver hand-rolled: send the
518/// request, retry transient *send* failures with backoff, break on success,
519/// and otherwise defer to a provider-specific `classify` closure for terminal
520/// vs. retry decisions on a non-success HTTP status.
521///
522/// - `send` builds and sends the request fresh each attempt (so per-attempt
523///   auth/header rebuilds are the caller's responsibility). It returns the
524///   `reqwest::Response` on success, or a [`SendOutcome`] distinguishing a
525///   transport error (retryable) from a fatal error (propagated immediately).
526/// - `classify` is invoked with the failed response, the current attempt count,
527///   and a `bool` indicating whether more retries remain; it consumes the
528///   response body as needed and returns a [`RetryDecision`].
529/// - `send_error` builds the terminal error when a send failure is not (or no
530///   longer) retryable, given the `reqwest::Error` and the attempt count.
531///
532/// On success returns the `reqwest::Response` plus the accumulated
533/// [`RetryMetadata`].
534pub 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                // A send failure never produced an HTTP response, so it bypasses
556                // the status-based retry below. Connection-level errors (incl. a
557                // stale pooled keep-alive connection, EVE-635) are transient —
558                // retry them with backoff, matching SDK `APIConnectionError`.
559                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// ============================================================================
618// Tests
619// ============================================================================
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_default_config_matches_official_sdks() {
627        // Defaults should match official Anthropic/OpenAI SDK behavior
628        let config = LlmRetryConfig::default();
629        assert_eq!(config.max_retries, 2); // SDK default is 2
630        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); // SDK uses ±25%
634    }
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, // No jitter for predictable test
643            ..Default::default()
644        };
645
646        // attempt 0: 1s * 2^0 = 1s
647        assert_eq!(config.calculate_backoff(0), Duration::from_secs(1));
648        // attempt 1: 1s * 2^1 = 2s
649        assert_eq!(config.calculate_backoff(1), Duration::from_secs(2));
650        // attempt 2: 1s * 2^2 = 4s
651        assert_eq!(config.calculate_backoff(2), Duration::from_secs(4));
652        // attempt 3: 1s * 2^3 = 8s
653        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        // attempt 0: 10s
667        assert_eq!(config.calculate_backoff(0), Duration::from_secs(10));
668        // attempt 1: 20s
669        assert_eq!(config.calculate_backoff(1), Duration::from_secs(20));
670        // attempt 2: 40s -> capped to 30s
671        assert_eq!(config.calculate_backoff(2), Duration::from_secs(30));
672        // attempt 3: 80s -> capped to 30s
673        assert_eq!(config.calculate_backoff(3), Duration::from_secs(30));
674    }
675
676    /// EVE-635: with jitter enabled the backoff must use a real RNG, so repeated
677    /// computations for the same attempt number diverge (no thundering herd).
678    #[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        // Jitter stays within ±25% of the 20s base for attempt 1.
696        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        // Should use retry-after, not exponential backoff
727        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        // Like official SDKs, if retry-after > 60s, use backoff instead
734        let config = LlmRetryConfig {
735            jitter_factor: 0.0, // No jitter for predictable test
736            ..Default::default()
737        };
738        let info = RateLimitInfo {
739            retry_after_secs: Some(120), // 2 minutes - too long
740            ..Default::default()
741        };
742
743        // Should fall back to exponential backoff, not use 120s
744        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(); // No retry-after
756
757        // Should use exponential backoff
758        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        // Official SDKs retry on: 408, 409, 429, 5xx (except 501)
781        assert!(is_transient_error(reqwest::StatusCode::REQUEST_TIMEOUT)); // 408
782        assert!(is_transient_error(reqwest::StatusCode::CONFLICT)); // 409
783        assert!(is_transient_error(reqwest::StatusCode::TOO_MANY_REQUESTS)); // 429
784        assert!(is_transient_error(
785            reqwest::StatusCode::INTERNAL_SERVER_ERROR
786        )); // 500
787        assert!(is_transient_error(reqwest::StatusCode::BAD_GATEWAY)); // 502
788        assert!(is_transient_error(reqwest::StatusCode::SERVICE_UNAVAILABLE)); // 503
789        assert!(is_transient_error(reqwest::StatusCode::GATEWAY_TIMEOUT)); // 504
790
791        // Not transient
792        assert!(!is_transient_error(reqwest::StatusCode::OK));
793        assert!(!is_transient_error(reqwest::StatusCode::BAD_REQUEST)); // 400
794        assert!(!is_transient_error(reqwest::StatusCode::UNAUTHORIZED)); // 401
795        assert!(!is_transient_error(reqwest::StatusCode::FORBIDDEN)); // 403
796        assert!(!is_transient_error(reqwest::StatusCode::NOT_FOUND)); // 404
797        assert!(!is_transient_error(reqwest::StatusCode::NOT_IMPLEMENTED)); // 501
798    }
799
800    /// A real send that cannot reach the server (connection refused on a closed
801    /// port) must be classified as a transient connection error — this is the
802    /// "error sending request for url" case that the retry loop now retries.
803    #[tokio::test]
804    async fn test_is_transient_send_error_on_connection_refused() {
805        // Bind then immediately drop a listener to obtain a port that is
806        // guaranteed to be closed, so the connect attempt is refused.
807        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        // EVE-806: the runtime stream-liveness watchdog message must be
871        // classified transient so the shared bounded retry path recovers it.
872        assert!(is_transient_error_message(
873            "provider stream stall: no tokens for 120s"
874        ));
875        // Untyped stream error carrying the stall message routes through the
876        // message fallback in is_transient_stream_error.
877        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        // Codex `usage_limit_reached` arrives as a 429 ("too many requests") but
886        // resets hours later — retrying inside the backoff window is pointless
887        // and would suppress the terminal user-facing message.
888        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        // Verify the constant matches SDK behavior
896        assert_eq!(MAX_RETRY_AFTER_SECS, 60);
897    }
898
899    // ------------------------------------------------------------------------
900    // retry_request executor tests (no live server; responses are synthesized)
901    // ------------------------------------------------------------------------
902
903    /// Build a `reqwest::Response` with a chosen status and body for testing.
904    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    /// Zero-backoff config so retries don't actually sleep in tests.
913    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                    // First two attempts return 429, third succeeds.
955                    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        // RetryNow must NOT increment the attempt counter.
1033        assert_eq!(meta.attempts, 0);
1034    }
1035
1036    #[tokio::test]
1037    async fn test_retry_request_send_error_exhausts() {
1038        // A send closure that always returns a transient send error must, after
1039        // exhausting retries, return the send_error-built terminal error.
1040        let config = fast_config(1);
1041
1042        // Obtain a real transient reqwest::Error (connection refused).
1043        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        // After 1 retry, message notes the retry count.
1066        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}