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    [
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
445/// Classify an in-band provider error using structured fields first.
446///
447/// Machine-readable provider codes are authoritative, followed by HTTP status.
448/// Message matching is retained only for legacy drivers that cannot preserve
449/// either field.
450pub 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
470// ============================================================================
471// Generic retry executor
472// ============================================================================
473
474/// Outcome of a failed *send* (no HTTP response was produced).
475///
476/// Lets the [`retry_request`] caller's send closure distinguish a hard error
477/// that must propagate immediately (e.g. an auth-header failure) from a
478/// `reqwest` transport error that should be classified for transient retry.
479pub enum SendOutcome {
480    /// A `reqwest` send error (connection/TLS/timeout). Classified via
481    /// [`is_transient_send_error`] for retry.
482    Send(reqwest::Error),
483    /// A non-transport error that must abort the loop immediately (e.g. an
484    /// `AuthHeaderProvider` failure resolved per attempt).
485    Fatal(AgentLoopError),
486}
487
488/// Decision returned by the per-attempt classifier when a response carries a
489/// non-success HTTP status.
490pub enum RetryDecision {
491    /// Retry after the given wait duration, recording the supplied rate-limit
492    /// info against the retry metadata.
493    Retry {
494        wait: Duration,
495        rate_limit_info: Option<RateLimitInfo>,
496    },
497    /// Retry immediately without counting an attempt or sleeping (e.g.
498    /// Anthropic's one-shot `max_tokens` fallback that mutates the request and
499    /// re-sends). Use sparingly — the classifier is responsible for ensuring
500    /// this cannot loop forever.
501    RetryNow,
502    /// Stop and return this terminal error to the caller.
503    Terminal(AgentLoopError),
504}
505
506/// Run the shared LLM request retry loop.
507///
508/// Reproduces the loop every native streaming driver hand-rolled: send the
509/// request, retry transient *send* failures with backoff, break on success,
510/// and otherwise defer to a provider-specific `classify` closure for terminal
511/// vs. retry decisions on a non-success HTTP status.
512///
513/// - `send` builds and sends the request fresh each attempt (so per-attempt
514///   auth/header rebuilds are the caller's responsibility). It returns the
515///   `reqwest::Response` on success, or a [`SendOutcome`] distinguishing a
516///   transport error (retryable) from a fatal error (propagated immediately).
517/// - `classify` is invoked with the failed response, the current attempt count,
518///   and a `bool` indicating whether more retries remain; it consumes the
519///   response body as needed and returns a [`RetryDecision`].
520/// - `send_error` builds the terminal error when a send failure is not (or no
521///   longer) retryable, given the `reqwest::Error` and the attempt count.
522///
523/// On success returns the `reqwest::Response` plus the accumulated
524/// [`RetryMetadata`].
525pub 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                // A send failure never produced an HTTP response, so it bypasses
547                // the status-based retry below. Connection-level errors (incl. a
548                // stale pooled keep-alive connection, EVE-635) are transient —
549                // retry them with backoff, matching SDK `APIConnectionError`.
550                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// ============================================================================
609// Tests
610// ============================================================================
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn test_default_config_matches_official_sdks() {
618        // Defaults should match official Anthropic/OpenAI SDK behavior
619        let config = LlmRetryConfig::default();
620        assert_eq!(config.max_retries, 2); // SDK default is 2
621        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); // SDK uses ±25%
625    }
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, // No jitter for predictable test
634            ..Default::default()
635        };
636
637        // attempt 0: 1s * 2^0 = 1s
638        assert_eq!(config.calculate_backoff(0), Duration::from_secs(1));
639        // attempt 1: 1s * 2^1 = 2s
640        assert_eq!(config.calculate_backoff(1), Duration::from_secs(2));
641        // attempt 2: 1s * 2^2 = 4s
642        assert_eq!(config.calculate_backoff(2), Duration::from_secs(4));
643        // attempt 3: 1s * 2^3 = 8s
644        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        // attempt 0: 10s
658        assert_eq!(config.calculate_backoff(0), Duration::from_secs(10));
659        // attempt 1: 20s
660        assert_eq!(config.calculate_backoff(1), Duration::from_secs(20));
661        // attempt 2: 40s -> capped to 30s
662        assert_eq!(config.calculate_backoff(2), Duration::from_secs(30));
663        // attempt 3: 80s -> capped to 30s
664        assert_eq!(config.calculate_backoff(3), Duration::from_secs(30));
665    }
666
667    /// EVE-635: with jitter enabled the backoff must use a real RNG, so repeated
668    /// computations for the same attempt number diverge (no thundering herd).
669    #[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        // Jitter stays within ±25% of the 20s base for attempt 1.
687        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        // Should use retry-after, not exponential backoff
718        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        // Like official SDKs, if retry-after > 60s, use backoff instead
725        let config = LlmRetryConfig {
726            jitter_factor: 0.0, // No jitter for predictable test
727            ..Default::default()
728        };
729        let info = RateLimitInfo {
730            retry_after_secs: Some(120), // 2 minutes - too long
731            ..Default::default()
732        };
733
734        // Should fall back to exponential backoff, not use 120s
735        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(); // No retry-after
747
748        // Should use exponential backoff
749        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        // Official SDKs retry on: 408, 409, 429, 5xx (except 501)
772        assert!(is_transient_error(reqwest::StatusCode::REQUEST_TIMEOUT)); // 408
773        assert!(is_transient_error(reqwest::StatusCode::CONFLICT)); // 409
774        assert!(is_transient_error(reqwest::StatusCode::TOO_MANY_REQUESTS)); // 429
775        assert!(is_transient_error(
776            reqwest::StatusCode::INTERNAL_SERVER_ERROR
777        )); // 500
778        assert!(is_transient_error(reqwest::StatusCode::BAD_GATEWAY)); // 502
779        assert!(is_transient_error(reqwest::StatusCode::SERVICE_UNAVAILABLE)); // 503
780        assert!(is_transient_error(reqwest::StatusCode::GATEWAY_TIMEOUT)); // 504
781
782        // Not transient
783        assert!(!is_transient_error(reqwest::StatusCode::OK));
784        assert!(!is_transient_error(reqwest::StatusCode::BAD_REQUEST)); // 400
785        assert!(!is_transient_error(reqwest::StatusCode::UNAUTHORIZED)); // 401
786        assert!(!is_transient_error(reqwest::StatusCode::FORBIDDEN)); // 403
787        assert!(!is_transient_error(reqwest::StatusCode::NOT_FOUND)); // 404
788        assert!(!is_transient_error(reqwest::StatusCode::NOT_IMPLEMENTED)); // 501
789    }
790
791    /// A real send that cannot reach the server (connection refused on a closed
792    /// port) must be classified as a transient connection error — this is the
793    /// "error sending request for url" case that the retry loop now retries.
794    #[tokio::test]
795    async fn test_is_transient_send_error_on_connection_refused() {
796        // Bind then immediately drop a listener to obtain a port that is
797        // guaranteed to be closed, so the connect attempt is refused.
798        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        // Codex `usage_limit_reached` arrives as a 429 ("too many requests") but
862        // resets hours later — retrying inside the backoff window is pointless
863        // and would suppress the terminal user-facing message.
864        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        // Verify the constant matches SDK behavior
872        assert_eq!(MAX_RETRY_AFTER_SECS, 60);
873    }
874
875    // ------------------------------------------------------------------------
876    // retry_request executor tests (no live server; responses are synthesized)
877    // ------------------------------------------------------------------------
878
879    /// Build a `reqwest::Response` with a chosen status and body for testing.
880    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    /// Zero-backoff config so retries don't actually sleep in tests.
889    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                    // First two attempts return 429, third succeeds.
931                    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        // RetryNow must NOT increment the attempt counter.
1009        assert_eq!(meta.attempts, 0);
1010    }
1011
1012    #[tokio::test]
1013    async fn test_retry_request_send_error_exhausts() {
1014        // A send closure that always returns a transient send error must, after
1015        // exhausting retries, return the send_error-built terminal error.
1016        let config = fast_config(1);
1017
1018        // Obtain a real transient reqwest::Error (connection refused).
1019        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        // After 1 retry, message notes the retry count.
1042        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}