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    /// Maximum wall-clock time spent recovering after the first transient
48    /// failure. The initial request is not charged against this budget.
49    pub max_retry_elapsed: Duration,
50}
51
52impl Default for LlmRetryConfig {
53    fn default() -> Self {
54        // Matches official Anthropic/OpenAI SDK defaults
55        Self {
56            max_retries: 2,
57            initial_backoff: Duration::from_secs(1),
58            max_backoff: Duration::from_secs(60),
59            backoff_multiplier: 2.0,
60            jitter_factor: 0.25,
61            max_retry_elapsed: Duration::from_secs(30),
62        }
63    }
64}
65
66impl LlmRetryConfig {
67    /// Create a config with no retries (fail immediately on rate limit)
68    pub fn no_retry() -> Self {
69        Self {
70            max_retries: 0,
71            ..Default::default()
72        }
73    }
74
75    /// Create a config with aggressive retry settings (more retries, longer waits)
76    pub fn aggressive() -> Self {
77        Self {
78            max_retries: 5,
79            initial_backoff: Duration::from_millis(500),
80            max_backoff: Duration::from_secs(120),
81            backoff_multiplier: 2.0,
82            jitter_factor: 0.25,
83            max_retry_elapsed: Duration::from_secs(120),
84        }
85    }
86
87    /// Calculate backoff duration for a given attempt number (0-indexed)
88    pub fn calculate_backoff(&self, attempt: u32) -> Duration {
89        let base_backoff =
90            self.initial_backoff.as_secs_f64() * self.backoff_multiplier.powi(attempt as i32);
91        let capped_backoff = base_backoff.min(self.max_backoff.as_secs_f64());
92
93        // Add jitter (±jitter_factor around the base)
94        // Official SDKs use: sleep_seconds * (1 - 0.25 * random()) where random is 0-1
95        // This gives range [0.75, 1.0] * base
96        let jitter = if self.jitter_factor > 0.0 {
97            let jitter_range = capped_backoff * self.jitter_factor;
98            // EVE-635: real RNG so concurrent clients hitting the same attempt
99            // number (e.g. a shared 429/503) do not retry in lockstep
100            // (thundering herd). Range [-1, 1).
101            let jitter_offset = rand::rng().random::<f64>() * 2.0 - 1.0;
102            jitter_range * jitter_offset
103        } else {
104            0.0
105        };
106
107        Duration::from_secs_f64((capped_backoff + jitter).max(0.0))
108    }
109}
110
111/// Reserve one retry wait inside the configured elapsed-time budget.
112///
113/// `started_at` is initialized on the first transient failure, so a slow but
114/// successful initial request does not consume recovery time. Returning `None`
115/// means the caller must surface the failure instead of starting another
116/// attempt that cannot finish within the strict budget.
117pub fn reserve_retry_wait(
118    config: &LlmRetryConfig,
119    started_at: &mut Option<tokio::time::Instant>,
120    wait: Duration,
121) -> Option<Duration> {
122    let started = *started_at.get_or_insert_with(tokio::time::Instant::now);
123    let remaining = config.max_retry_elapsed.checked_sub(started.elapsed())?;
124    (wait < remaining).then_some(wait)
125}
126
127/// Remaining wall-clock retry budget after recovery has started.
128pub fn remaining_retry_time(
129    config: &LlmRetryConfig,
130    started_at: Option<tokio::time::Instant>,
131) -> Option<Duration> {
132    started_at.and_then(|started| config.max_retry_elapsed.checked_sub(started.elapsed()))
133}
134
135/// Rate limit information extracted from provider response headers
136#[derive(Debug, Clone, Default)]
137pub struct RateLimitInfo {
138    /// Retry-After header value (seconds to wait)
139    pub retry_after_secs: Option<u64>,
140    /// Requests remaining before limit
141    pub requests_remaining: Option<u32>,
142    /// Tokens remaining before limit
143    pub tokens_remaining: Option<u32>,
144    /// Time until request limit resets
145    pub requests_reset: Option<String>,
146    /// Time until token limit resets
147    pub tokens_reset: Option<String>,
148    /// Provider-specific limit type that was hit
149    pub limit_type: Option<RateLimitType>,
150}
151
152/// Type of rate limit that was exceeded
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum RateLimitType {
155    /// Requests per minute/hour
156    Requests,
157    /// Input tokens per minute
158    InputTokens,
159    /// Output tokens per minute
160    OutputTokens,
161    /// Total tokens per minute
162    TotalTokens,
163    /// Unknown or unspecified
164    Unknown,
165}
166
167impl RateLimitInfo {
168    /// Get the recommended wait duration, preferring retry-after if available.
169    /// Caps retry-after at MAX_RETRY_AFTER_SECS (60s) like official SDKs.
170    pub fn recommended_wait(&self, config: &LlmRetryConfig, attempt: u32) -> Duration {
171        if let Some(retry_after) = self.retry_after_secs {
172            // Provider told us how long to wait
173            // Cap at 60s like official SDKs - if longer, use backoff instead
174            if retry_after > 0 && retry_after <= MAX_RETRY_AFTER_SECS {
175                return Duration::from_secs(retry_after);
176            }
177        }
178        // Fall back to exponential backoff
179        config.calculate_backoff(attempt)
180    }
181
182    /// Parse rate limit info from Anthropic response headers
183    pub fn from_anthropic_headers(headers: &reqwest::header::HeaderMap) -> Self {
184        let mut info = Self::default();
185
186        // Try non-standard retry-after-ms header first (milliseconds)
187        // Used by some providers for sub-second precision
188        if let Some(val) = headers.get("retry-after-ms")
189            && let Ok(s) = val.to_str()
190            && let Ok(ms) = s.parse::<u64>()
191        {
192            // Convert ms to seconds (round up)
193            info.retry_after_secs = Some(ms.div_ceil(1000));
194        }
195
196        // retry-after header (standard, seconds)
197        if info.retry_after_secs.is_none()
198            && let Some(val) = headers.get("retry-after")
199            && let Ok(s) = val.to_str()
200        {
201            info.retry_after_secs = s.parse().ok();
202        }
203
204        // anthropic-ratelimit-requests-remaining
205        if let Some(val) = headers.get("anthropic-ratelimit-requests-remaining")
206            && let Ok(s) = val.to_str()
207        {
208            info.requests_remaining = s.parse().ok();
209        }
210
211        // anthropic-ratelimit-tokens-remaining
212        if let Some(val) = headers.get("anthropic-ratelimit-tokens-remaining")
213            && let Ok(s) = val.to_str()
214        {
215            info.tokens_remaining = s.parse().ok();
216        }
217
218        // anthropic-ratelimit-requests-reset
219        if let Some(val) = headers.get("anthropic-ratelimit-requests-reset")
220            && let Ok(s) = val.to_str()
221        {
222            info.requests_reset = Some(s.to_string());
223        }
224
225        // anthropic-ratelimit-tokens-reset
226        if let Some(val) = headers.get("anthropic-ratelimit-tokens-reset")
227            && let Ok(s) = val.to_str()
228        {
229            info.tokens_reset = Some(s.to_string());
230        }
231
232        // Determine limit type from remaining values
233        if info.requests_remaining == Some(0) {
234            info.limit_type = Some(RateLimitType::Requests);
235        } else if info.tokens_remaining == Some(0) {
236            info.limit_type = Some(RateLimitType::InputTokens);
237        }
238
239        info
240    }
241
242    /// Parse rate limit info from OpenAI-compatible response headers.
243    pub fn from_openai_headers(headers: &reqwest::header::HeaderMap) -> Self {
244        let mut info = Self::default();
245
246        // Try non-standard retry-after-ms header first (milliseconds)
247        if let Some(val) = headers.get("retry-after-ms")
248            && let Ok(s) = val.to_str()
249            && let Ok(ms) = s.parse::<u64>()
250        {
251            // Convert ms to seconds (round up)
252            info.retry_after_secs = Some(ms.div_ceil(1000));
253        }
254
255        // retry-after header (standard, seconds)
256        if info.retry_after_secs.is_none()
257            && let Some(val) = headers.get("retry-after")
258            && let Ok(s) = val.to_str()
259        {
260            info.retry_after_secs = s.parse().ok();
261        }
262
263        // x-ratelimit-remaining-requests
264        if let Some(val) = headers.get("x-ratelimit-remaining-requests")
265            && let Ok(s) = val.to_str()
266        {
267            info.requests_remaining = s.parse().ok();
268        }
269
270        // x-ratelimit-remaining-tokens
271        if let Some(val) = headers.get("x-ratelimit-remaining-tokens")
272            && let Ok(s) = val.to_str()
273        {
274            // OpenAI sometimes returns -1 for unlimited
275            let val: i64 = s.parse().unwrap_or(-1);
276            if val >= 0 {
277                info.tokens_remaining = Some(val as u32);
278            }
279        }
280
281        // x-ratelimit-reset-requests (e.g., "1s", "6m0s")
282        if let Some(val) = headers.get("x-ratelimit-reset-requests")
283            && let Ok(s) = val.to_str()
284        {
285            info.requests_reset = Some(s.to_string());
286            // Try to parse as seconds for retry-after fallback
287            if info.retry_after_secs.is_none() {
288                info.retry_after_secs = parse_duration_string(s);
289            }
290        }
291
292        // x-ratelimit-reset-tokens
293        if let Some(val) = headers.get("x-ratelimit-reset-tokens")
294            && let Ok(s) = val.to_str()
295        {
296            info.tokens_reset = Some(s.to_string());
297        }
298
299        // Determine limit type
300        if info.requests_remaining == Some(0) {
301            info.limit_type = Some(RateLimitType::Requests);
302        } else if info.tokens_remaining == Some(0) {
303            info.limit_type = Some(RateLimitType::TotalTokens);
304        }
305
306        info
307    }
308}
309
310/// Parse duration strings like "1s", "6m0s", "1h30m"
311fn parse_duration_string(s: &str) -> Option<u64> {
312    let s = s.trim();
313    if s.is_empty() {
314        return None;
315    }
316
317    let mut total_secs: u64 = 0;
318    let mut current_num = String::new();
319
320    for c in s.chars() {
321        if c.is_ascii_digit() {
322            current_num.push(c);
323        } else {
324            let num: u64 = current_num.parse().ok()?;
325            current_num.clear();
326
327            match c {
328                'h' => total_secs += num * 3600,
329                'm' => total_secs += num * 60,
330                's' => total_secs += num,
331                _ => return None,
332            }
333        }
334    }
335
336    if total_secs > 0 {
337        Some(total_secs)
338    } else {
339        None
340    }
341}
342
343/// Metadata about retry attempts for observability
344#[derive(Debug, Clone, Default)]
345pub struct RetryMetadata {
346    /// Number of retry attempts made (0 = succeeded on first try)
347    pub attempts: u32,
348    /// Total time spent waiting between retries
349    pub total_retry_wait: Duration,
350    /// Wall-clock time consumed after automatic recovery began.
351    pub total_retry_elapsed: Duration,
352    /// Rate limit info from the last 429 response (if any)
353    pub last_rate_limit_info: Option<RateLimitInfo>,
354}
355
356impl RetryMetadata {
357    /// Check if any retries were made
358    pub fn had_retries(&self) -> bool {
359        self.attempts > 0
360    }
361
362    /// Create metadata for a successful first attempt
363    pub fn first_attempt_success() -> Self {
364        Self::default()
365    }
366
367    /// Record a retry attempt
368    pub fn record_retry(
369        &mut self,
370        wait_duration: Duration,
371        rate_limit_info: Option<RateLimitInfo>,
372    ) {
373        self.attempts += 1;
374        self.total_retry_wait += wait_duration;
375        if rate_limit_info.is_some() {
376            self.last_rate_limit_info = rate_limit_info;
377        }
378    }
379
380    /// Merge retries consumed by a nested request/reconnect layer.
381    pub fn absorb(&mut self, other: RetryMetadata) {
382        self.attempts = self.attempts.saturating_add(other.attempts);
383        self.total_retry_wait = self.total_retry_wait.saturating_add(other.total_retry_wait);
384        self.total_retry_elapsed = self
385            .total_retry_elapsed
386            .saturating_add(other.total_retry_elapsed);
387        if other.last_rate_limit_info.is_some() {
388            self.last_rate_limit_info = other.last_rate_limit_info;
389        }
390    }
391}
392
393/// Check if an HTTP status code is a rate limit error (429)
394pub fn is_rate_limit_status(status: reqwest::StatusCode) -> bool {
395    status == reqwest::StatusCode::TOO_MANY_REQUESTS
396}
397
398/// Check if an error is a transient error that should be retried.
399///
400/// Matches official SDK behavior - retries on:
401/// - 408 Request Timeout
402/// - 409 Conflict (lock timeout)
403/// - 429 Too Many Requests (rate limit)
404/// - 5xx Server errors (except 501 Not Implemented)
405pub fn is_transient_error(status: reqwest::StatusCode) -> bool {
406    // 408 Request Timeout
407    if status == reqwest::StatusCode::REQUEST_TIMEOUT {
408        return true;
409    }
410    // 409 Conflict (often lock timeout in APIs)
411    if status == reqwest::StatusCode::CONFLICT {
412        return true;
413    }
414    // 429 Too Many Requests
415    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
416        return true;
417    }
418    // 5xx Server errors (except 501 Not Implemented)
419    if status.is_server_error() && status != reqwest::StatusCode::NOT_IMPLEMENTED {
420        return true;
421    }
422    false
423}
424
425/// Check if a `reqwest` error raised while *sending* a request is a transient
426/// connection-level failure that is safe to retry.
427///
428/// These are errors where the request never produced an HTTP response: a
429/// connect/TLS failure, a connect timeout, or a generic send failure on a
430/// (possibly stale) pooled keep-alive connection — surfaced by reqwest as
431/// `error sending request for url ...`. The official Anthropic/OpenAI SDKs
432/// retry exactly these as `APIConnectionError`. Because the server produced no
433/// response, the request had no effect, so retrying is safe.
434///
435/// This matters since EVE-635 introduced a process-wide shared connection pool:
436/// a keep-alive connection the peer has already closed is only discovered when
437/// the next request tries to reuse it, and that send fails here rather than
438/// returning an HTTP status — so it must be retried alongside the 429/5xx path
439/// (see `is_transient_error`).
440pub fn is_transient_send_error(err: &reqwest::Error) -> bool {
441    err.is_connect() || err.is_timeout() || err.is_request()
442}
443
444/// Build the user-facing message for a request that failed to send, noting how
445/// many retries were exhausted so it mirrors the HTTP-status error path.
446pub fn send_error_message(err: &reqwest::Error, attempts: u32) -> String {
447    if attempts > 0 {
448        format!("Failed to send request: {err} (after {attempts} retries)")
449    } else {
450        format!("Failed to send request: {err}")
451    }
452}
453
454/// Check if an in-band provider error message looks transient and safe to retry.
455///
456/// This complements HTTP-status-based retry detection for streaming APIs that can
457/// emit retryable provider failures inside an otherwise successful event stream.
458pub fn is_transient_error_message(message: &str) -> bool {
459    // A subscription/plan usage limit (e.g. Codex `usage_limit_reached`) surfaces
460    // as a 429 ("too many requests") but does not recover within the retry
461    // window — it resets hours later at `resets_at`. Treating it as transient
462    // would waste retries and suppress the human-readable error message the
463    // reason atom emits for terminal failures, so it is explicitly non-transient.
464    if crate::user_facing_error::is_usage_limit_message(message) {
465        return false;
466    }
467
468    let msg = message.trim().to_ascii_lowercase();
469
470    // EVE-806: the runtime's stream-liveness watchdog aborts a stream that
471    // produced no tokens within its window with "provider stream stall: no
472    // tokens for Ns". A stall before any output is equivalent to a dropped
473    // connection, so it is transient and safe for the shared bounded retry path
474    // to recover.
475    if msg.contains("provider stream stall") {
476        return true;
477    }
478
479    [
480        "server_error",
481        "internal server error",
482        "overloaded",
483        "overloaded_error",
484        "rate limit",
485        "too many requests",
486        "request timeout",
487        "timed out",
488        "service unavailable",
489        "bad gateway",
490        "gateway timeout",
491        "temporarily unavailable",
492    ]
493    .iter()
494    .any(|needle| msg.contains(needle))
495}
496
497/// Classify an in-band provider error using structured fields first.
498///
499/// Machine-readable provider codes are authoritative, followed by HTTP status.
500/// Message matching is retained only for legacy drivers that cannot preserve
501/// either field.
502pub fn is_transient_stream_error(error: &crate::driver_registry::LlmStreamError) -> bool {
503    if let Some(code) = error.code.as_deref()
504        && let Some(kind) = crate::error::LlmErrorKind::from_provider_code(code)
505    {
506        return matches!(
507            kind,
508            crate::error::LlmErrorKind::RateLimited | crate::error::LlmErrorKind::Unavailable
509        );
510    }
511
512    if let Some(status) = error
513        .status
514        .and_then(|status| reqwest::StatusCode::from_u16(status).ok())
515    {
516        return is_transient_error(status);
517    }
518
519    is_transient_error_message(&error.message)
520}
521
522// ============================================================================
523// Generic retry executor
524// ============================================================================
525
526/// Outcome of a failed *send* (no HTTP response was produced).
527///
528/// Lets the [`retry_request`] caller's send closure distinguish a hard error
529/// that must propagate immediately (e.g. an auth-header failure) from a
530/// `reqwest` transport error that should be classified for transient retry.
531pub enum SendOutcome {
532    /// A `reqwest` send error (connection/TLS/timeout). Classified via
533    /// [`is_transient_send_error`] for retry.
534    Send(reqwest::Error),
535    /// A non-transport error that must abort the loop immediately (e.g. an
536    /// `AuthHeaderProvider` failure resolved per attempt).
537    Fatal(AgentLoopError),
538}
539
540/// Decision returned by the per-attempt classifier when a response carries a
541/// non-success HTTP status.
542pub enum RetryDecision {
543    /// Retry after the given wait duration, recording the supplied rate-limit
544    /// info against the retry metadata.
545    Retry {
546        wait: Duration,
547        rate_limit_info: Option<RateLimitInfo>,
548    },
549    /// Retry immediately without counting an attempt or sleeping (e.g.
550    /// Anthropic's one-shot `max_tokens` fallback that mutates the request and
551    /// re-sends). Use sparingly — the classifier is responsible for ensuring
552    /// this cannot loop forever.
553    RetryNow,
554    /// Stop and return this terminal error to the caller.
555    Terminal(AgentLoopError),
556}
557
558/// Run the shared LLM request retry loop.
559///
560/// Reproduces the loop every native streaming driver hand-rolled: send the
561/// request, retry transient *send* failures with backoff, break on success,
562/// and otherwise defer to a provider-specific `classify` closure for terminal
563/// vs. retry decisions on a non-success HTTP status.
564///
565/// - `send` builds and sends the request fresh each attempt (so per-attempt
566///   auth/header rebuilds are the caller's responsibility). It returns the
567///   `reqwest::Response` on success, or a [`SendOutcome`] distinguishing a
568///   transport error (retryable) from a fatal error (propagated immediately).
569/// - `classify` is invoked with the failed response, the current attempt count,
570///   and a `bool` indicating whether more retries remain; it consumes the
571///   response body as needed and returns a [`RetryDecision`].
572/// - `send_error` builds the terminal error when a send failure is not (or no
573///   longer) retryable, given the `reqwest::Error` and the attempt count.
574///
575/// On success returns the `reqwest::Response` plus the accumulated
576/// [`RetryMetadata`].
577pub async fn retry_request<S, SFut, C, CFut, E>(
578    config: &LlmRetryConfig,
579    driver_name: &str,
580    mut send: S,
581    mut classify: C,
582    send_error: E,
583) -> Result<(reqwest::Response, RetryMetadata), AgentLoopError>
584where
585    S: FnMut() -> SFut,
586    SFut: Future<Output = Result<reqwest::Response, SendOutcome>>,
587    C: FnMut(reqwest::Response, u32, bool) -> CFut,
588    CFut: Future<Output = RetryDecision>,
589    E: Fn(&reqwest::Error, u32) -> AgentLoopError,
590{
591    let mut retry_metadata = RetryMetadata::default();
592    let mut retry_started_at = None;
593
594    let response = loop {
595        let send_result = if let Some(remaining) = remaining_retry_time(config, retry_started_at) {
596            match tokio::time::timeout(remaining, send()).await {
597                Ok(result) => result,
598                Err(_) => {
599                    return Err(AgentLoopError::llm_kind(
600                        crate::error::LlmErrorKind::Unavailable,
601                        format!(
602                            "{driver_name} retry time budget exhausted after {} retries over {:.1}s",
603                            retry_metadata.attempts,
604                            config.max_retry_elapsed.as_secs_f64()
605                        ),
606                    )
607                    .with_retry_metadata(&retry_metadata));
608                }
609            }
610        } else {
611            send().await
612        };
613        let response = match send_result {
614            Ok(response) => response,
615            Err(SendOutcome::Fatal(err)) => return Err(err),
616            Err(SendOutcome::Send(e)) => {
617                // A send failure never produced an HTTP response, so it bypasses
618                // the status-based retry below. Connection-level errors (incl. a
619                // stale pooled keep-alive connection, EVE-635) are transient —
620                // retry them with backoff, matching SDK `APIConnectionError`.
621                if is_transient_send_error(&e) && retry_metadata.attempts < config.max_retries {
622                    let proposed_wait = config.calculate_backoff(retry_metadata.attempts);
623                    let Some(wait_duration) =
624                        reserve_retry_wait(config, &mut retry_started_at, proposed_wait)
625                    else {
626                        return Err(send_error(&e, retry_metadata.attempts)
627                            .with_retry_metadata(&retry_metadata));
628                    };
629                    tracing::warn!(
630                        error = %e,
631                        driver = driver_name,
632                        attempt = retry_metadata.attempts + 1,
633                        max_retries = config.max_retries,
634                        wait_secs = wait_duration.as_secs_f64(),
635                        "transient connection error sending request, retrying"
636                    );
637                    retry_metadata.record_retry(wait_duration, None);
638                    tokio::time::sleep(wait_duration).await;
639                    continue;
640                }
641                return Err(
642                    send_error(&e, retry_metadata.attempts).with_retry_metadata(&retry_metadata)
643                );
644            }
645        };
646
647        let status = response.status();
648        if status.is_success() {
649            break response;
650        }
651
652        let can_retry = is_transient_error(status) && retry_metadata.attempts < config.max_retries;
653        match classify(response, retry_metadata.attempts, can_retry).await {
654            RetryDecision::Retry {
655                wait,
656                rate_limit_info,
657            } => {
658                let Some(wait) = reserve_retry_wait(config, &mut retry_started_at, wait) else {
659                    return Err(AgentLoopError::llm_kind(
660                        crate::error::LlmErrorKind::Unavailable,
661                        format!(
662                            "{driver_name} retry time budget exhausted after {} retries over {:.1}s",
663                            retry_metadata.attempts,
664                            config.max_retry_elapsed.as_secs_f64()
665                        ),
666                    )
667                    .with_retry_metadata(&retry_metadata));
668                };
669                tracing::warn!(
670                    status = %status,
671                    driver = driver_name,
672                    attempt = retry_metadata.attempts + 1,
673                    max_retries = config.max_retries,
674                    wait_secs = wait.as_secs_f64(),
675                    "rate limit or transient error, retrying"
676                );
677                retry_metadata.record_retry(wait, rate_limit_info);
678                tokio::time::sleep(wait).await;
679                continue;
680            }
681            RetryDecision::RetryNow => continue,
682            RetryDecision::Terminal(err) => {
683                return Err(err.with_retry_metadata(&retry_metadata));
684            }
685        }
686    };
687
688    if retry_metadata.had_retries() {
689        retry_metadata.total_retry_elapsed = retry_started_at
690            .map(|started| started.elapsed())
691            .unwrap_or_default();
692        tracing::info!(
693            driver = driver_name,
694            attempts = retry_metadata.attempts,
695            total_wait_secs = retry_metadata.total_retry_wait.as_secs_f64(),
696            "request succeeded after retries"
697        );
698    }
699
700    Ok((response, retry_metadata))
701}
702
703// ============================================================================
704// Tests
705// ============================================================================
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn test_default_config_matches_official_sdks() {
713        // Defaults should match official Anthropic/OpenAI SDK behavior
714        let config = LlmRetryConfig::default();
715        assert_eq!(config.max_retries, 2); // SDK default is 2
716        assert_eq!(config.initial_backoff, Duration::from_secs(1));
717        assert_eq!(config.max_backoff, Duration::from_secs(60));
718        assert_eq!(config.backoff_multiplier, 2.0);
719        assert!((config.jitter_factor - 0.25).abs() < 0.001); // SDK uses ±25%
720    }
721
722    #[test]
723    fn test_calculate_backoff_exponential() {
724        let config = LlmRetryConfig {
725            initial_backoff: Duration::from_secs(1),
726            max_backoff: Duration::from_secs(60),
727            backoff_multiplier: 2.0,
728            jitter_factor: 0.0, // No jitter for predictable test
729            ..Default::default()
730        };
731
732        // attempt 0: 1s * 2^0 = 1s
733        assert_eq!(config.calculate_backoff(0), Duration::from_secs(1));
734        // attempt 1: 1s * 2^1 = 2s
735        assert_eq!(config.calculate_backoff(1), Duration::from_secs(2));
736        // attempt 2: 1s * 2^2 = 4s
737        assert_eq!(config.calculate_backoff(2), Duration::from_secs(4));
738        // attempt 3: 1s * 2^3 = 8s
739        assert_eq!(config.calculate_backoff(3), Duration::from_secs(8));
740    }
741
742    #[test]
743    fn test_calculate_backoff_capped() {
744        let config = LlmRetryConfig {
745            initial_backoff: Duration::from_secs(10),
746            max_backoff: Duration::from_secs(30),
747            backoff_multiplier: 2.0,
748            jitter_factor: 0.0,
749            ..Default::default()
750        };
751
752        // attempt 0: 10s
753        assert_eq!(config.calculate_backoff(0), Duration::from_secs(10));
754        // attempt 1: 20s
755        assert_eq!(config.calculate_backoff(1), Duration::from_secs(20));
756        // attempt 2: 40s -> capped to 30s
757        assert_eq!(config.calculate_backoff(2), Duration::from_secs(30));
758        // attempt 3: 80s -> capped to 30s
759        assert_eq!(config.calculate_backoff(3), Duration::from_secs(30));
760    }
761
762    /// EVE-635: with jitter enabled the backoff must use a real RNG, so repeated
763    /// computations for the same attempt number diverge (no thundering herd).
764    #[test]
765    fn test_backoff_jitter_is_randomized() {
766        let config = LlmRetryConfig {
767            initial_backoff: Duration::from_secs(10),
768            max_backoff: Duration::from_secs(60),
769            backoff_multiplier: 2.0,
770            jitter_factor: 0.25,
771            ..Default::default()
772        };
773        let samples: std::collections::HashSet<u128> = (0..20)
774            .map(|_| config.calculate_backoff(1).as_nanos())
775            .collect();
776        assert!(
777            samples.len() > 1,
778            "jittered backoff should vary across calls, got {} distinct value(s)",
779            samples.len()
780        );
781        // Jitter stays within ±25% of the 20s base for attempt 1.
782        for _ in 0..50 {
783            let secs = config.calculate_backoff(1).as_secs_f64();
784            assert!(
785                (15.0..=25.0).contains(&secs),
786                "backoff {secs}s out of range"
787            );
788        }
789    }
790
791    #[test]
792    fn test_parse_duration_string() {
793        assert_eq!(parse_duration_string("1s"), Some(1));
794        assert_eq!(parse_duration_string("30s"), Some(30));
795        assert_eq!(parse_duration_string("1m"), Some(60));
796        assert_eq!(parse_duration_string("6m0s"), Some(360));
797        assert_eq!(parse_duration_string("1h"), Some(3600));
798        assert_eq!(parse_duration_string("1h30m"), Some(5400));
799        assert_eq!(parse_duration_string("1h30m45s"), Some(5445));
800        assert_eq!(parse_duration_string(""), None);
801        assert_eq!(parse_duration_string("invalid"), None);
802    }
803
804    #[test]
805    fn test_rate_limit_info_recommended_wait_with_retry_after() {
806        let config = LlmRetryConfig::default();
807        let info = RateLimitInfo {
808            retry_after_secs: Some(10),
809            ..Default::default()
810        };
811
812        // Should use retry-after, not exponential backoff
813        assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(10));
814        assert_eq!(info.recommended_wait(&config, 5), Duration::from_secs(10));
815    }
816
817    #[test]
818    fn test_rate_limit_info_recommended_wait_capped_at_60s() {
819        // Like official SDKs, if retry-after > 60s, use backoff instead
820        let config = LlmRetryConfig {
821            jitter_factor: 0.0, // No jitter for predictable test
822            ..Default::default()
823        };
824        let info = RateLimitInfo {
825            retry_after_secs: Some(120), // 2 minutes - too long
826            ..Default::default()
827        };
828
829        // Should fall back to exponential backoff, not use 120s
830        assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
831    }
832
833    #[test]
834    fn test_rate_limit_info_recommended_wait_fallback() {
835        let config = LlmRetryConfig {
836            initial_backoff: Duration::from_secs(1),
837            backoff_multiplier: 2.0,
838            jitter_factor: 0.0,
839            ..Default::default()
840        };
841        let info = RateLimitInfo::default(); // No retry-after
842
843        // Should use exponential backoff
844        assert_eq!(info.recommended_wait(&config, 0), Duration::from_secs(1));
845        assert_eq!(info.recommended_wait(&config, 1), Duration::from_secs(2));
846    }
847
848    #[test]
849    fn test_retry_metadata_record() {
850        let mut meta = RetryMetadata::default();
851        assert!(!meta.had_retries());
852        assert_eq!(meta.attempts, 0);
853
854        meta.record_retry(Duration::from_secs(1), None);
855        assert!(meta.had_retries());
856        assert_eq!(meta.attempts, 1);
857        assert_eq!(meta.total_retry_wait, Duration::from_secs(1));
858
859        meta.record_retry(Duration::from_secs(2), None);
860        assert_eq!(meta.attempts, 2);
861        assert_eq!(meta.total_retry_wait, Duration::from_secs(3));
862    }
863
864    #[test]
865    fn test_is_transient_error_matches_official_sdks() {
866        // Official SDKs retry on: 408, 409, 429, 5xx (except 501)
867        assert!(is_transient_error(reqwest::StatusCode::REQUEST_TIMEOUT)); // 408
868        assert!(is_transient_error(reqwest::StatusCode::CONFLICT)); // 409
869        assert!(is_transient_error(reqwest::StatusCode::TOO_MANY_REQUESTS)); // 429
870        assert!(is_transient_error(
871            reqwest::StatusCode::INTERNAL_SERVER_ERROR
872        )); // 500
873        assert!(is_transient_error(reqwest::StatusCode::BAD_GATEWAY)); // 502
874        assert!(is_transient_error(reqwest::StatusCode::SERVICE_UNAVAILABLE)); // 503
875        assert!(is_transient_error(reqwest::StatusCode::GATEWAY_TIMEOUT)); // 504
876
877        // Not transient
878        assert!(!is_transient_error(reqwest::StatusCode::OK));
879        assert!(!is_transient_error(reqwest::StatusCode::BAD_REQUEST)); // 400
880        assert!(!is_transient_error(reqwest::StatusCode::UNAUTHORIZED)); // 401
881        assert!(!is_transient_error(reqwest::StatusCode::FORBIDDEN)); // 403
882        assert!(!is_transient_error(reqwest::StatusCode::NOT_FOUND)); // 404
883        assert!(!is_transient_error(reqwest::StatusCode::NOT_IMPLEMENTED)); // 501
884    }
885
886    /// A real send that cannot reach the server (connection refused on a closed
887    /// port) must be classified as a transient connection error — this is the
888    /// "error sending request for url" case that the retry loop now retries.
889    #[tokio::test]
890    async fn test_is_transient_send_error_on_connection_refused() {
891        // Bind then immediately drop a listener to obtain a port that is
892        // guaranteed to be closed, so the connect attempt is refused.
893        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
894        let addr = listener.local_addr().unwrap();
895        drop(listener);
896
897        let err = reqwest::Client::new()
898            .get(format!("http://{addr}/"))
899            .send()
900            .await
901            .expect_err("request to a closed port should fail");
902
903        assert!(
904            is_transient_send_error(&err),
905            "connection-refused send error should be transient: {err:?}"
906        );
907    }
908
909    #[test]
910    fn test_is_transient_error_message_detects_provider_server_errors() {
911        assert!(is_transient_error_message(
912            "server_error: An error occurred while processing your request."
913        ));
914        assert!(is_transient_error_message("Rate limit exceeded"));
915        assert!(is_transient_error_message(
916            "Service temporarily unavailable"
917        ));
918    }
919
920    #[test]
921    fn test_is_transient_error_message_rejects_non_retryable_messages() {
922        assert!(!is_transient_error_message(
923            "invalid_request_error: bad tool schema"
924        ));
925        assert!(!is_transient_error_message("Model not available: gpt-99"));
926    }
927
928    #[test]
929    fn structured_stream_error_prefers_code_and_status_over_message() {
930        use crate::driver_registry::LlmStreamError;
931
932        assert!(is_transient_stream_error(&LlmStreamError::provider(
933            Some("processing_error"),
934            None,
935            "An error occurred while processing your request.",
936        )));
937        assert!(is_transient_stream_error(&LlmStreamError::provider(
938            None::<String>,
939            Some(503),
940            "opaque failure",
941        )));
942        assert!(!is_transient_stream_error(&LlmStreamError::provider(
943            Some("invalid_request_error"),
944            Some(503),
945            "server unavailable",
946        )));
947        assert!(!is_transient_stream_error(&LlmStreamError::provider(
948            Some("insufficient_quota"),
949            Some(429),
950            "rate limit",
951        )));
952    }
953
954    #[test]
955    fn test_provider_stream_stall_is_transient() {
956        // EVE-806: the runtime stream-liveness watchdog message must be
957        // classified transient so the shared bounded retry path recovers it.
958        assert!(is_transient_error_message(
959            "provider stream stall: no tokens for 120s"
960        ));
961        // Untyped stream error carrying the stall message routes through the
962        // message fallback in is_transient_stream_error.
963        use crate::driver_registry::LlmStreamError;
964        assert!(is_transient_stream_error(&LlmStreamError::new(
965            "provider stream stall: no tokens for 120s"
966        )));
967    }
968
969    #[test]
970    fn test_is_transient_error_message_treats_usage_limit_as_non_transient() {
971        // Codex `usage_limit_reached` arrives as a 429 ("too many requests") but
972        // resets hours later — retrying inside the backoff window is pointless
973        // and would suppress the terminal user-facing message.
974        assert!(!is_transient_error_message(
975            "Codex API error (429 Too Many Requests): {\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":1783767823}}"
976        ));
977    }
978
979    #[test]
980    fn test_max_retry_after_constant() {
981        // Verify the constant matches SDK behavior
982        assert_eq!(MAX_RETRY_AFTER_SECS, 60);
983    }
984
985    // ------------------------------------------------------------------------
986    // retry_request executor tests (no live server; responses are synthesized)
987    // ------------------------------------------------------------------------
988
989    /// Build a `reqwest::Response` with a chosen status and body for testing.
990    fn fake_response(status: u16, body: &str) -> reqwest::Response {
991        let http_response = http::Response::builder()
992            .status(status)
993            .body(body.to_string())
994            .unwrap();
995        reqwest::Response::from(http_response)
996    }
997
998    /// Zero-backoff config so retries don't actually sleep in tests.
999    fn fast_config(max_retries: u32) -> LlmRetryConfig {
1000        LlmRetryConfig {
1001            max_retries,
1002            initial_backoff: Duration::from_millis(0),
1003            max_backoff: Duration::from_millis(0),
1004            backoff_multiplier: 1.0,
1005            jitter_factor: 0.0,
1006            ..Default::default()
1007        }
1008    }
1009
1010    #[tokio::test]
1011    async fn test_retry_request_success_first_try() {
1012        let config = fast_config(2);
1013        let (resp, meta) = retry_request(
1014            &config,
1015            "TestDriver",
1016            || async { Ok(fake_response(200, "ok")) },
1017            |_resp, _attempt, _can_retry| async {
1018                RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1019            },
1020            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1021        )
1022        .await
1023        .expect("should succeed");
1024        assert!(resp.status().is_success());
1025        assert_eq!(meta.attempts, 0);
1026        assert!(!meta.had_retries());
1027    }
1028
1029    #[tokio::test]
1030    async fn test_retry_request_retries_then_succeeds() {
1031        let config = fast_config(3);
1032        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1033        let calls_send = calls.clone();
1034        let (resp, meta) = retry_request(
1035            &config,
1036            "TestDriver",
1037            move || {
1038                let calls = calls_send.clone();
1039                async move {
1040                    let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1041                    // First two attempts return 429, third succeeds.
1042                    if n < 2 {
1043                        Ok(fake_response(429, "rate limited"))
1044                    } else {
1045                        Ok(fake_response(200, "ok"))
1046                    }
1047                }
1048            },
1049            |_resp, _attempt, can_retry| async move {
1050                assert!(can_retry, "429 within budget should be retryable");
1051                RetryDecision::Retry {
1052                    wait: Duration::from_millis(0),
1053                    rate_limit_info: None,
1054                }
1055            },
1056            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1057        )
1058        .await
1059        .expect("should eventually succeed");
1060        assert!(resp.status().is_success());
1061        assert_eq!(meta.attempts, 2);
1062    }
1063
1064    #[tokio::test]
1065    async fn test_retry_request_terminal_decision_propagates() {
1066        let config = fast_config(2);
1067        let result = retry_request(
1068            &config,
1069            "TestDriver",
1070            || async { Ok(fake_response(400, "bad request")) },
1071            |_resp, _attempt, _can_retry| async {
1072                RetryDecision::Terminal(AgentLoopError::llm("classified terminal"))
1073            },
1074            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1075        )
1076        .await;
1077        let err = result.expect_err("terminal decision should error");
1078        assert!(err.to_string().contains("classified terminal"));
1079    }
1080
1081    #[tokio::test]
1082    async fn test_retry_request_retry_now_does_not_count_attempt() {
1083        let config = fast_config(2);
1084        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1085        let calls_send = calls.clone();
1086        let (resp, meta) = retry_request(
1087            &config,
1088            "TestDriver",
1089            move || {
1090                let calls = calls_send.clone();
1091                async move {
1092                    let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1093                    if n == 0 {
1094                        Ok(fake_response(400, "max_tokens too large"))
1095                    } else {
1096                        Ok(fake_response(200, "ok"))
1097                    }
1098                }
1099            },
1100            {
1101                let mut used_fallback = false;
1102                move |_resp, _attempt, _can_retry| {
1103                    let do_fallback = !used_fallback;
1104                    used_fallback = true;
1105                    async move {
1106                        if do_fallback {
1107                            RetryDecision::RetryNow
1108                        } else {
1109                            RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1110                        }
1111                    }
1112                }
1113            },
1114            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1115        )
1116        .await
1117        .expect("RetryNow then success");
1118        assert!(resp.status().is_success());
1119        // RetryNow must NOT increment the attempt counter.
1120        assert_eq!(meta.attempts, 0);
1121    }
1122
1123    #[tokio::test]
1124    async fn test_retry_request_send_error_exhausts() {
1125        // A send closure that always returns a transient send error must, after
1126        // exhausting retries, return the send_error-built terminal error.
1127        let config = fast_config(1);
1128
1129        // Obtain a real transient reqwest::Error (connection refused).
1130        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1131        let addr = listener.local_addr().unwrap();
1132        drop(listener);
1133        let make_err = || async {
1134            reqwest::Client::new()
1135                .get(format!("http://{addr}/"))
1136                .send()
1137                .await
1138                .expect_err("closed port")
1139        };
1140
1141        let result = retry_request(
1142            &config,
1143            "TestDriver",
1144            move || async move { Err(SendOutcome::Send(make_err().await)) },
1145            |_resp, _attempt, _can_retry| async {
1146                RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1147            },
1148            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1149        )
1150        .await;
1151        let err = result.expect_err("send errors should exhaust to terminal");
1152        // After 1 retry, message notes the retry count.
1153        assert!(err.to_string().contains("after 1 retries"), "got: {err}");
1154    }
1155
1156    #[tokio::test]
1157    async fn test_retry_request_fatal_send_propagates_immediately() {
1158        let config = fast_config(3);
1159        let result = retry_request(
1160            &config,
1161            "TestDriver",
1162            || async { Err(SendOutcome::Fatal(AgentLoopError::llm("auth failed"))) },
1163            |_resp, _attempt, _can_retry| async {
1164                RetryDecision::Terminal(AgentLoopError::llm("unreachable"))
1165            },
1166            |e, attempts| AgentLoopError::llm(send_error_message(e, attempts)),
1167        )
1168        .await;
1169        let err = result.expect_err("fatal send should propagate");
1170        assert!(err.to_string().contains("auth failed"));
1171    }
1172}