Skip to main content

loopctl/stream/
handler.rs

1//! Resilient LLM stream handling.
2//!
3//! [`StreamHandler`] wraps [`ApiClient::stream_messages`] with retry, timeout,
4//! rate-limit detection, and fallback behaviour. Configure it with
5//! [`StreamTimeoutConfig`], [`StreamRetryConfig`], and [`RateLimitConfig`].
6//!
7//! # Architecture
8//!
9//! A turn flows through three phases:
10//!
11//! 1. **Initialization with retry.** Open the SSE connection via
12//!    `stream_messages` and wait for the first event. On failure, back off and
13//!    retry per [`StreamRetryConfig`] (exponential, jittered).
14//! 2. **Event processing.** Assemble the response while enforcing three guards:
15//!    a per-event timeout, a total-stream timeout, and rate-limit detection
16//!    (429/503/529 mid-stream). Cancellation is honoured throughout.
17//! 3. **Fallback.** If streaming is exhausted, retry the request as a
18//!    single-shot [`ApiClient::create_message`] call.
19//!
20//! # Quick Start
21//!
22//! ```rust
23//! use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig};
24//!
25//! let handler = StreamHandler::new();
26//!
27//! // Or with custom config:
28//! let handler = StreamHandler::new().with_timeout_config(
29//!     StreamTimeoutConfig {
30//!         initial_event_timeout: std::time::Duration::from_secs(60),
31//!         ..Default::default()
32//!     },
33//! );
34//! ```
35
36use crate::api::ApiClient;
37use crate::api::error::{ApiError, http_status_from_message, parse_retry_after};
38use crate::cancel::CancelSignal;
39use crate::message::Message;
40use crate::stream::rate_limit;
41use crate::stream::{StreamAccumulator, StreamEvent, StreamStopReason, Usage};
42use futures::StreamExt;
43use futures::stream::Stream;
44use std::fmt;
45use std::pin::Pin;
46use std::sync::Arc;
47use std::time::{Duration, Instant};
48
49/// Configuration for the [`StreamHandler`]'s timeout behaviour.
50///
51/// Controls how long the handler waits for events at each phase of the
52/// streaming lifecycle. The defaults are production-ready for typical
53/// LLM API interactions.
54///
55/// # Timeouts
56///
57/// | Timeout                 | Phase   | Default | Purpose                       |
58/// |-------------------------|---------|---------|-------------------------------|
59/// | `initial_event_timeout` | Init    | 120s    | First event after stream open |
60/// | `per_event_timeout`     | Process | 180s    | Between consecutive events    |
61/// | `total_stream_timeout`  | Process | 300s    | Maximum total stream duration |
62///
63/// # Example
64///
65/// ```rust
66/// use loopctl::stream::handler::StreamTimeoutConfig;
67/// use std::time::Duration;
68///
69/// let config = StreamTimeoutConfig {
70///     initial_event_timeout: Duration::from_secs(60),
71///     per_event_timeout: Duration::from_secs(180),
72///     ..Default::default()
73/// };
74/// assert_eq!(config.total_stream_timeout, Duration::from_secs(300));
75/// ```
76#[derive(Debug, Clone)]
77pub struct StreamTimeoutConfig {
78    /// Timeout for the first event after opening the stream.
79    ///
80    /// Most critical timeout — if the API server never sends
81    /// the first event, the stream hangs forever. Set to a generous value
82    /// since the model may need time to begin generating.
83    pub initial_event_timeout: Duration,
84
85    /// Timeout between consecutive events during normal processing.
86    ///
87    /// If no event arrives within this window, the handler increments a
88    /// consecutive-timeout counter. If [`max_consecutive_timeouts`](Self::max_consecutive_timeouts)
89    /// is reached, the handler triggers recovery.
90    pub per_event_timeout: Duration,
91
92    /// Maximum total duration for a single stream, regardless of activity.
93    ///
94    /// Even if events are flowing, the stream is terminated after this
95    /// duration. Prevents runaway streams from very long model responses.
96    pub total_stream_timeout: Duration,
97
98    /// Maximum consecutive per-event timeouts before triggering recovery.
99    ///
100    /// When zero events have been received (empty stream), a lower
101    /// threshold is used: `min(2, max_consecutive_timeouts)`.
102    pub max_consecutive_timeouts: u32,
103
104    /// Whether to fall back to [`ApiClient::create_message`]
105    /// when streaming exhausts all retries.
106    ///
107    /// When `true`, the handler will attempt a non-streaming request as a
108    /// last resort. When `false`, the handler returns an error instead.
109    pub fallback_to_non_streaming: bool,
110}
111
112impl Default for StreamTimeoutConfig {
113    fn default() -> Self {
114        Self {
115            initial_event_timeout: Duration::from_mins(2),
116            per_event_timeout: Duration::from_mins(3),
117            total_stream_timeout: Duration::from_mins(5),
118            max_consecutive_timeouts: 10,
119            fallback_to_non_streaming: true,
120        }
121    }
122}
123
124impl StreamTimeoutConfig {
125    /// Validates the configuration, returning an error message if invalid.
126    ///
127    /// Checks that all timeout durations are non-zero and that
128    /// `total_stream_timeout` ≥ `initial_event_timeout`.
129    ///
130    /// # Errors
131    ///
132    /// Returns a string describing the first validation failure.
133    ///
134    /// # Example
135    ///
136    /// ```rust
137    /// use loopctl::stream::handler::StreamTimeoutConfig;
138    /// use std::time::Duration;
139    ///
140    /// assert!(StreamTimeoutConfig::default().validate().is_ok());
141    ///
142    /// let bad = StreamTimeoutConfig {
143    ///     initial_event_timeout: Duration::ZERO,
144    ///     ..Default::default()
145    /// };
146    /// assert!(bad.validate().is_err());
147    /// ```
148    pub fn validate(&self) -> Result<(), String> {
149        if self.initial_event_timeout.is_zero() {
150            return Err("initial_event_timeout must be non-zero".to_string());
151        }
152        if self.per_event_timeout.is_zero() {
153            return Err("per_event_timeout must be non-zero".to_string());
154        }
155        if self.total_stream_timeout.is_zero() {
156            return Err("total_stream_timeout must be non-zero".to_string());
157        }
158        if self.total_stream_timeout == Duration::MAX {
159            return Err(
160                "total_stream_timeout must be finite — Duration::MAX silently disables the \
161                 total deadline, both backoff clamps, and the non-streaming fallback deadline; \
162                 construct the config directly (as passthrough does) to opt into that"
163                    .to_string(),
164            );
165        }
166        if self.total_stream_timeout < self.initial_event_timeout {
167            return Err(format!(
168                "total_stream_timeout ({:?}) must be >= initial_event_timeout ({:?})",
169                self.total_stream_timeout, self.initial_event_timeout
170            ));
171        }
172
173        if self.max_consecutive_timeouts == 0 {
174            return Err("max_consecutive_timeouts must be >= 1".to_string());
175        }
176        Ok(())
177    }
178}
179
180/// Configuration for retry behaviour when stream initialization fails.
181///
182/// Uses exponential backoff with jitter to avoid thundering herd when
183/// multiple agents retry simultaneously.
184///
185/// # Backoff Formula
186///
187/// ```text
188/// delay = min(base_delay * 2^attempt, max_delay) * (1.0 ± jitter)
189/// ```
190///
191/// # Example
192///
193/// ```rust
194/// use loopctl::stream::handler::StreamRetryConfig;
195///
196/// let config = StreamRetryConfig {
197///     max_retries: 5,
198///     base_delay_ms: 200,
199///     ..Default::default()
200/// };
201/// assert_eq!(config.max_delay_ms, 10_000);
202/// ```
203#[derive(Debug, Clone)]
204pub struct StreamRetryConfig {
205    /// Maximum number of retry attempts for stream initialization.
206    ///
207    /// Each attempt opens a new stream and waits for the first event
208    /// with the [`initial_event_timeout`](StreamTimeoutConfig::initial_event_timeout).
209    pub max_retries: u32,
210
211    /// Base delay in milliseconds before the first retry.
212    ///
213    /// Doubled on each subsequent retry attempt.
214    pub base_delay_ms: u64,
215
216    /// Maximum delay in milliseconds between retries.
217    ///
218    /// Caps the exponential growth so retries don't take too long.
219    pub max_delay_ms: u64,
220
221    /// Jitter factor (0.0 to 1.0) applied to the delay.
222    ///
223    /// Prevents thundering herd when multiple agents retry
224    /// simultaneously. A factor of 0.1 means the delay varies
225    /// by ±10%.
226    pub jitter_factor: f64,
227}
228
229impl Default for StreamRetryConfig {
230    fn default() -> Self {
231        Self {
232            max_retries: 3,
233            base_delay_ms: 100,
234            max_delay_ms: 10_000,
235            jitter_factor: 0.1,
236        }
237    }
238}
239
240impl StreamRetryConfig {
241    /// Calculate the backoff delay for a given attempt number (0-indexed).
242    ///
243    /// Returns the delay as a [`Duration`], capped at
244    /// [`max_delay_ms`](Self::max_delay_ms). This is the *raw* exponential
245    /// backoff with no jitter; for the jittered delay used by
246    /// [`StreamHandler`] on transport retries, use
247    /// [`jittered_base_delay`](Self::jittered_base_delay).
248    ///
249    /// # Example
250    ///
251    /// ```rust
252    /// use loopctl::stream::handler::StreamRetryConfig;
253    /// use std::time::Duration;
254    ///
255    /// let config = StreamRetryConfig::default();
256    /// assert_eq!(config.base_delay(0), Duration::from_millis(100));
257    /// assert_eq!(config.base_delay(1), Duration::from_millis(200));
258    /// assert_eq!(config.base_delay(2), Duration::from_millis(400));
259    /// ```
260    #[must_use]
261    pub fn base_delay(&self, attempt: u32) -> Duration {
262        let delay_ms = self
263            .base_delay_ms
264            .saturating_mul(1u64.checked_shl(attempt).unwrap_or(u64::MAX));
265        Duration::from_millis(delay_ms.min(self.max_delay_ms))
266    }
267
268    /// The raw exponential backoff with [`jitter_factor`](Self::jitter_factor) applied.
269    ///
270    /// Returns [`base_delay`](Self::base_delay)`(attempt)` scaled by a random
271    /// factor in `[1 - jitter_factor, 1 + jitter_factor]`, drawn from
272    /// [`fastrand`]. Concurrent retries with the same attempt number get
273    /// different delays — avoiding a thundering herd where every client
274    /// retries on the same tick. When [`jitter_factor`](Self::jitter_factor)
275    /// is `0.0`, returns [`base_delay`](Self::base_delay) unchanged (no
276    /// randomness, no allocation).
277    ///
278    /// This is the delay [`StreamHandler`] sleeps between transport-retry
279    /// attempts; [`base_delay`](Self::base_delay) is the deterministic core
280    /// it composes on.
281    ///
282    /// # Example
283    ///
284    /// ```rust
285    /// use loopctl::stream::handler::StreamRetryConfig;
286    /// use std::time::Duration;
287    ///
288    /// let config = StreamRetryConfig { jitter_factor: 0.0, ..Default::default() };
289    /// // With no jitter, the jittered delay equals the raw backoff exactly.
290    /// assert_eq!(config.jittered_base_delay(1), config.base_delay(1));
291    /// ```
292    #[must_use]
293    pub fn jittered_base_delay(&self, attempt: u32) -> Duration {
294        let base = self.base_delay(attempt);
295        if self.jitter_factor == 0.0 {
296            return base;
297        }
298        let f = Self::random_signed_fraction() * self.jitter_factor;
299        base.mul_f64(1.0 + f)
300    }
301
302    /// A random signed fraction in `[-1.0, 1.0)` from [`fastrand`].
303    ///
304    /// Draws a uniform `f64` in `[0.0, 1.0)` from fastrand's thread-local
305    /// Wyrand PRNG and remaps it to `[-1.0, 1.0)`. Each call produces a
306    /// different result, so concurrent retries spread their backoffs.
307    #[must_use]
308    fn random_signed_fraction() -> f64 {
309        (fastrand::f64() - 0.5) * 2.0
310    }
311
312    /// Validates the configuration, returning an error message if invalid.
313    ///
314    /// Checks that `jitter_factor` is finite and within `0.0..=1.0`,
315    /// and that all delay values are non-zero with `max_delay_ms` ≥ `base_delay_ms`.
316    ///
317    /// # Errors
318    ///
319    /// Returns a string describing the first validation failure.
320    ///
321    /// # Example
322    ///
323    /// ```rust
324    /// use loopctl::stream::handler::StreamRetryConfig;
325    ///
326    /// assert!(StreamRetryConfig::default().validate().is_ok());
327    ///
328    /// let bad = StreamRetryConfig { jitter_factor: 1.5, ..Default::default() };
329    /// assert!(bad.validate().is_err());
330    /// ```
331    pub fn validate(&self) -> Result<(), String> {
332        if self.base_delay_ms == 0 {
333            return Err("base_delay_ms must be non-zero".to_string());
334        }
335        if self.max_delay_ms == 0 {
336            return Err("max_delay_ms must be non-zero".to_string());
337        }
338        if self.max_delay_ms < self.base_delay_ms {
339            return Err(format!(
340                "max_delay_ms ({}) must be >= base_delay_ms ({})",
341                self.max_delay_ms, self.base_delay_ms
342            ));
343        }
344        if !self.jitter_factor.is_finite() {
345            return Err(format!(
346                "jitter_factor must be finite, got {}",
347                self.jitter_factor
348            ));
349        }
350        if !(0.0..=1.0).contains(&self.jitter_factor) {
351            return Err(format!(
352                "jitter_factor must be in 0.0..=1.0, got {}",
353                self.jitter_factor
354            ));
355        }
356        Ok(())
357    }
358}
359
360/// Policy for handling 429 / 503 rate-limit responses from LLM providers.
361///
362/// Governs backoff and retry behaviour when the server signals that the client
363/// should slow down: a 429 *Too Many Requests*, a 503 *Service Unavailable*,
364/// or a 529 *Overloaded*. All three can carry a `Retry-After` hint that this
365/// config can honour.
366///
367/// Distinct from [`StreamRetryConfig`], which covers generic
368/// stream-initialization transport failures (the connection itself failed to
369/// open).
370///
371/// # Example
372///
373/// ```
374/// use loopctl::stream::handler::RateLimitConfig;
375/// use std::time::Duration;
376///
377/// let cfg = RateLimitConfig {
378///     default_delay: Duration::from_secs(2),
379///     fallback_after_retries: 2,
380///     ..Default::default()
381/// };
382/// assert!(cfg.validate().is_ok());
383/// ```
384#[derive(Debug, Clone)]
385pub struct RateLimitConfig {
386    /// Whether to honour a 429/503 `Retry-After` value when the server
387    /// provides one.
388    ///
389    /// When `true` (the default), [`backoff`](Self::backoff) returns the
390    /// server-advised delay (capped at [`max_delay`](Self::max_delay)). When
391    /// `false`, the server's hint is ignored and
392    /// [`default_delay`](Self::default_delay) is always used.
393    pub respect_retry_after: bool,
394
395    /// Backoff used when the server gives no `Retry-After`.
396    ///
397    /// Also used unconditionally when
398    /// [`respect_retry_after`](Self::respect_retry_after) is `false`. Defaults
399    /// to 5s — long enough to let a transient burst clear, short enough that a
400    /// missing header doesn't stall the agent.
401    pub default_delay: Duration,
402
403    /// Upper bound on any single rate-limit backoff.
404    ///
405    /// Caps both the server's `Retry-After` (when honoured) and
406    /// [`default_delay`](Self::default_delay) so a misbehaving provider cannot
407    /// stall the agent indefinitely.
408    pub max_delay: Duration,
409
410    /// Advisory per-minute request ceiling for proactive throttling (0 = unset).
411    ///
412    /// This field is **not read at runtime** by the reactive rate-limit handler
413    /// — it does not throttle on its own. It is the value a caller feeds to
414    /// [`RateLimiter::new`](crate::stream::rate_limit::RateLimiter::new) when
415    /// attaching a proactive limiter via
416    /// [`StreamHandler::with_rate_limiter`](crate::stream::handler::StreamHandler::with_rate_limiter).
417    /// Zero (the default) means no proactive throttling is configured; reactive handling
418    /// of server-returned 429/503 responses is unaffected and governed by the
419    /// other fields below.
420    pub requests_per_minute: u32,
421
422    /// Number of rate-limit retries before switching to a fallback model.
423    ///
424    /// After this many retries on the same model, the next rate limit triggers
425    /// a fallback to a different model (if one is configured).
426    pub fallback_after_retries: u32,
427
428    /// Hard cap on rate-limit retries for a single turn.
429    ///
430    /// Once this many retries have been exhausted, the turn fails outright.
431    /// Distinct from [`fallback_after_retries`](Self::fallback_after_retries),
432    /// which controls the *escalation* threshold to a fallback model, not the
433    /// hard stop after which the turn gives up entirely.
434    pub max_retries: u32,
435}
436
437impl Default for RateLimitConfig {
438    fn default() -> Self {
439        Self {
440            respect_retry_after: true,
441            default_delay: Duration::from_secs(5),
442            max_delay: Duration::from_mins(1),
443            requests_per_minute: 0,
444            fallback_after_retries: 3,
445            max_retries: 5,
446        }
447    }
448}
449
450impl RateLimitConfig {
451    /// Validate the policy.
452    ///
453    /// `default_delay` and `max_delay` must be non-zero, `max_delay` must be at
454    /// least `default_delay`, and `max_retries` must be at least 1.
455    ///
456    /// # Errors
457    ///
458    /// Returns a human-readable description of the first violated constraint.
459    ///
460    /// ```
461    /// use loopctl::stream::handler::RateLimitConfig;
462    /// use std::time::Duration;
463    ///
464    /// assert!(RateLimitConfig::default().validate().is_ok());
465    /// let bad = RateLimitConfig { max_retries: 0, ..Default::default() };
466    /// assert!(bad.validate().is_err());
467    /// ```
468    pub fn validate(&self) -> Result<(), String> {
469        if self.default_delay == Duration::ZERO {
470            return Err("default_delay must be non-zero".into());
471        }
472        if self.max_delay < self.default_delay {
473            return Err("max_delay must be >= default_delay".into());
474        }
475        if self.max_retries == 0 {
476            return Err("max_retries must be >= 1".into());
477        }
478        if self.fallback_after_retries > self.max_retries {
479            return Err(format!(
480                "fallback_after_retries ({}) must be <= max_retries ({})",
481                self.fallback_after_retries, self.max_retries
482            ));
483        }
484        Ok(())
485    }
486
487    /// Effective backoff for a detected rate limit given an optional server hint.
488    ///
489    /// - If `server_hint` is `Some(d)` and `respect_retry_after` is `true`,
490    ///   returns `min(d, max_delay)`.
491    /// - Otherwise returns `min(default_delay, max_delay)`.
492    ///
493    /// ```
494    /// use loopctl::stream::handler::RateLimitConfig;
495    /// use std::time::Duration;
496    ///
497    /// let cfg = RateLimitConfig::default();
498    /// assert_eq!(cfg.backoff(Some(Duration::from_secs(12))), Duration::from_secs(12));
499    /// assert_eq!(cfg.backoff(None), cfg.default_delay);
500    /// ```
501    #[must_use]
502    pub fn backoff(&self, server_hint: Option<Duration>) -> Duration {
503        match server_hint {
504            Some(d) if self.respect_retry_after => d.min(self.max_delay),
505            _ => self.default_delay.min(self.max_delay),
506        }
507    }
508}
509
510/// Which kind of rate-limit / overload response was detected.
511///
512/// Set by [`DetectedRateLimit::detect`] when classifying an [`ApiError`]; the
513/// distinction matters because the two responses come from different failure
514/// modes (a hard per-account quota vs. a transient capacity signal) even
515/// though both honour `Retry-After`.
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub enum RateLimitKind {
518    /// HTTP 429 Too Many Requests.
519    ///
520    /// The canonical rate-limit signal: the caller has exceeded a per-account
521    /// or per-key quota. The server typically sends a `Retry-After` hint;
522    /// [`backoff`](RateLimitConfig::backoff) honours it when
523    /// [`respect_retry_after`](RateLimitConfig::respect_retry_after) is set.
524    RateLimited,
525
526    /// HTTP 503 Service Unavailable / 529 Overloaded.
527    ///
528    /// A rate-limit-adjacent transient: the provider is overloaded rather than
529    /// enforcing a quota. Treated the same as [`RateLimited`](Self::RateLimited)
530    /// for backoff purposes (it honours `Retry-After`), but surfaced as a
531    /// distinct kind so callers can log or route it differently.
532    Overloaded,
533}
534
535/// A rate-limit response detected on an established stream.
536///
537/// Produced by [`DetectedRateLimit::detect`] from an [`ApiError`]. Carries the
538/// parsed `Retry-After` (when available) so the caller can back off accordingly
539/// without re-parsing.
540#[derive(Debug, Clone)]
541pub struct DetectedRateLimit {
542    /// The detected rate-limit class.
543    ///
544    /// Either [`RateLimitKind::RateLimited`] (HTTP 429) or
545    /// [`RateLimitKind::Overloaded`] (HTTP 503/529). Determines nothing on its
546    /// own — both kinds honour `Retry-After` — but lets the caller distinguish
547    /// a quota hit from a transient capacity signal.
548    pub kind: RateLimitKind,
549
550    /// The server-advised delay, parsed from the `Retry-After` header.
551    ///
552    /// `None` when the header was absent or could not be parsed as a number of
553    /// seconds or an HTTP-date. When `None`, the caller falls back to
554    /// [`RateLimitConfig::default_delay`].
555    pub retry_after: Option<Duration>,
556
557    /// The original error message, preserved verbatim.
558    ///
559    /// Kept so the caller can log the provider's wording, include it in a
560    /// fallback-model prompt, or surface it to the user without losing the
561    /// diagnostic detail that [`detect`](Self::detect) collapsed into
562    /// [`kind`](Self::kind).
563    pub message: String,
564}
565
566impl DetectedRateLimit {
567    /// Inspect an [`ApiError`] for a rate-limit signature.
568    ///
569    /// Returns `Some(DetectedRateLimit)` when the error is:
570    /// - the structured [`ApiError::RateLimit`] variant (typed `retry_after`;
571    ///   kind `RateLimited` for 429-shaped messages, `Overloaded` when the
572    ///   message carries a 503/529 status),
573    /// - an `Api(String)` whose body contains `"rate limit"` or `"429"`, or
574    /// - an `Http(String)` whose `"HTTP {status}:"` prefix indicates 429
575    ///   (kind [`RateLimitKind::RateLimited`]) or 503/529 (kind
576    ///   [`RateLimitKind::Overloaded`]).
577    ///
578    /// Returns `None` for everything else (500s, auth errors, generic
579    /// transport failures, etc.).
580    #[must_use]
581    pub fn detect(err: &crate::api::error::ApiError) -> Option<Self> {
582        match err {
583            ApiError::RateLimit {
584                retry_after,
585                message,
586            } => Some(Self {
587                kind: match http_status_from_message(message) {
588                    Some(503 | 529) => RateLimitKind::Overloaded,
589                    _ => RateLimitKind::RateLimited,
590                },
591                retry_after: *retry_after,
592                message: message.clone(),
593            }),
594            ApiError::Api(msg) => {
595                let lower = msg.to_lowercase();
596                if lower.contains("rate limit") || lower.contains("429") {
597                    Some(Self {
598                        kind: RateLimitKind::RateLimited,
599                        retry_after: parse_retry_after(msg),
600                        message: msg.clone(),
601                    })
602                } else {
603                    None
604                }
605            }
606            ApiError::Http(msg) => {
607                let kind = match http_status_from_message(msg) {
608                    Some(429) => RateLimitKind::RateLimited,
609                    Some(503 | 529) => RateLimitKind::Overloaded,
610                    _ => return None,
611                };
612                Some(Self {
613                    kind,
614                    retry_after: parse_retry_after(msg),
615                    message: msg.clone(),
616                })
617            }
618            _ => None,
619        }
620    }
621}
622
623/// Clamp a rate-limit backoff so it cannot sleep past the turn's `total_deadline`.
624///
625/// `None` passes the delay through unchanged. When the remaining time to the
626/// deadline is smaller than `delay`, the remaining time is returned (zero once
627/// the deadline has passed). All arithmetic is checked; the worst case is
628/// `Duration::ZERO`, never a panic.
629fn clamp_delay_to_deadline(delay: Duration, deadline: Option<Instant>) -> Duration {
630    let Some(deadline) = deadline else {
631        return delay;
632    };
633    let now = Instant::now();
634    let Some(remaining) = deadline.checked_duration_since(now) else {
635        return Duration::ZERO;
636    };
637    delay.min(remaining)
638}
639
640/// Outcome of a rate-limit retry decision.
641///
642/// Returned by [`StreamHandler::rate_limit_retry`] for each detected rate
643/// limit on the current model. The variants form a three-step escalation
644/// ladder: retry in place while the count is low, escalate to the model
645/// circuit breaker once it crosses
646/// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries), and
647/// give up entirely once it crosses
648/// [`max_retries`](RateLimitConfig::max_retries).
649///
650/// Exactly one of the two terminal rungs is reachable per configuration:
651/// [`Escalate`](Self::Escalate) ends the turn, so the count never grows
652/// past it while it sits below
653/// [`max_retries`](RateLimitConfig::max_retries). The default config
654/// (`fallback_after_retries: 3 < max_retries: 5`) therefore always
655/// escalates; setting `fallback_after_retries == max_retries` removes the
656/// escalation rung and retries to the hard ceiling instead.
657#[derive(Debug)]
658enum RateLimitRetry {
659    /// Escalate to the model circuit breaker.
660    ///
661    /// Returned once the per-model retry count exceeds
662    /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries).
663    /// The caller trips the breaker, which routes subsequent turns to a
664    /// fallback model if one is configured; if not, the escalation has nowhere
665    /// to go and the turn fails.
666    Escalate {
667        /// Number of rate-limit retries honored before this escalation.
668        ///
669        /// Always strictly greater than
670        /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries)
671        /// — the count that triggered the escalation, incremented before the
672        /// decision is made. Surfaced for logging and for the
673        /// [`RateLimitEscalation`](crate::error::LoopError::RateLimitEscalation)
674        /// error payload.
675        attempts: u32,
676
677        /// The server-advised delay from the triggering response.
678        ///
679        /// The raw `Retry-After` from [`DetectedRateLimit`] (`None` if the
680        /// header was absent). Carried unmodified — clamping to
681        /// [`max_delay`](RateLimitConfig::max_delay) happens in
682        /// [`backoff`](RateLimitConfig::backoff) on the retry path, not here.
683        /// Preserved so the escalation consumer can log or forward the
684        /// provider's hint.
685        retry_after: Option<Duration>,
686    },
687
688    /// Give up on retrying the current model.
689    ///
690    /// Returned once the per-model retry count exceeds
691    /// [`max_retries`](RateLimitConfig::max_retries) — the hard stop after
692    /// which retrying the same model is pointless. Reachable only when
693    /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries)
694    /// equals [`max_retries`](RateLimitConfig::max_retries) (see the enum
695    /// docs for why). Distinct from [`Escalate`](Self::Escalate):
696    /// escalation hands off to the circuit breaker (and a fallback model);
697    /// `HardStop` skips that hand-off — when
698    /// [`fallback_to_non_streaming`](StreamTimeoutConfig::fallback_to_non_streaming)
699    /// is enabled the turn gets one last-chance non-streaming request,
700    /// otherwise it fails outright.
701    HardStop,
702
703    /// Sleep for `delay`, then retry the current model.
704    ///
705    /// Returned while the retry count is below both
706    /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries) and
707    /// [`max_retries`](RateLimitConfig::max_retries). The delay is the
708    /// [`backoff`](RateLimitConfig::backoff) for the detected response,
709    /// further clamped to the remaining time before the turn's
710    /// `total_stream_timeout` deadline so a large `Retry-After` cannot overrun
711    /// the turn budget.
712    Retry(Duration),
713}
714
715/// What `stream_turn`'s error arm should do after a failed stream event.
716///
717/// Produced by [`decide_rate_limit_error`](StreamHandler::decide_rate_limit_error)
718/// and [`decide_transport_error`](StreamHandler::decide_transport_error). The
719/// generator body matches on this to propagate the error, try a non-streaming
720/// fallback, or sleep and retry — keeping the decision logic out of the
721/// `async_stream` body.
722///
723/// This indirection exists because `async_stream::try_stream!` forbids
724/// extracting `yield` / `?` into helper functions. The helpers return a
725/// plain enum; the generator body is the only place the actual side-effect
726/// (`yield`, `return`, `continue`) happens.
727enum ErrorAction {
728    /// Propagate the error and end the stream immediately.
729    ///
730    /// Carries the [`StreamHandlerError`] the caller propagates via `?`. The
731    /// generator yields nothing further — the stream terminates with this
732    /// error as the final item.
733    Fail(StreamHandlerError),
734
735    /// Attempt a non-streaming fallback before giving up.
736    ///
737    /// Carries the [`StreamOutcome`] from the failed stream attempt, which
738    /// [`fallback_non_streaming`](StreamHandler::fallback_non_streaming) uses
739    /// to build a diagnostic if the fallback also fails. The generator calls
740    /// the fallback, yields a [`HandlerEvent::Fallback`] on success, and
741    /// returns. On fallback failure the error propagates as if `Fail`.
742    TryFallback(Option<StreamOutcome>),
743
744    /// Sleep for the delay, then retry the outer stream loop.
745    ///
746    /// The delay is already clamped to the total-stream deadline by the
747    /// decision method, so the generator just calls `sleep_cancellable` and
748    /// `continue 'outer`. The retry counter has already been incremented.
749    Retry(Duration),
750}
751
752/// A failed event poll paired with its retry classification.
753///
754/// Carries the [`StreamHandlerError`] the generator propagates plus the
755/// [`ApiError::is_retryable`] verdict captured while the originating provider
756/// error was still typed — before it was flattened into an outcome's message
757/// string. [`decide_transport_error`](StreamHandler::decide_transport_error)
758/// reads the verdict to fail fast on permanent errors instead of spending the
759/// transport-retry ladder on them.
760struct StreamFailure {
761    /// The error to propagate when the failure is terminal.
762    ///
763    /// Built from the failing outcome exactly as it flows to the consumer;
764    /// the retry verdict never alters the error's shape.
765    error: StreamHandlerError,
766
767    /// Whether the underlying failure is transient.
768    ///
769    /// `true` for timeouts, 5xx responses, 408 request timeouts, 429 rate
770    /// limits, and connection-level transport errors; `false` for permanent
771    /// classes (authentication rejections, other 4xx) and cancellation.
772    retryable: bool,
773}
774
775impl StreamFailure {
776    /// Wrap a transient failure whose full retry treatment applies.
777    ///
778    /// Used for the timeout outcomes, which have no [`ApiError`] to consult —
779    /// a deadline that fired is by definition worth another attempt while the
780    /// budget lasts.
781    fn transient(error: StreamHandlerError) -> Self {
782        Self {
783            error,
784            retryable: true,
785        }
786    }
787}
788
789/// Recover the [`StreamOutcome`] a [`StreamHandlerError`] carries, if any.
790///
791/// Only [`InitFailed`](StreamHandlerError::InitFailed) and
792/// [`StreamFailed`](StreamHandlerError::StreamFailed) carry one (the outcome
793/// that was in progress when the error was raised); every other variant maps
794/// to `None`. The caller uses the recovered outcome to route the failure into
795/// the correct retry budget — a [`RateLimited`](StreamOutcome::RateLimited)
796/// outcome draws on [`RateLimitConfig`], distinct from the transport-retry
797/// budget, so a rate-limit storm cannot exhaust transport retries (nor vice
798/// versa).
799fn carried_outcome(error: &StreamHandlerError) -> Option<StreamOutcome> {
800    match error {
801        StreamHandlerError::InitFailed(o) | StreamHandlerError::StreamFailed(o) => {
802            Some(o.to_owned())
803        }
804        _ => None,
805    }
806}
807
808/// Sleep for `delay`, or return [`StreamHandlerError::Cancelled`] if the cancel
809/// signal fires first.
810///
811/// # Errors
812///
813/// Returns [`StreamHandlerError::Cancelled`] if `cancel` is signalled before the
814/// sleep elapses.
815async fn sleep_cancellable(
816    delay: Duration,
817    cancel: &Arc<CancelSignal>,
818) -> Result<(), StreamHandlerError> {
819    tokio::select! {
820        () = tokio::time::sleep(delay) => Ok(()),
821        () = cancel.notified() => Err(StreamHandlerError::Cancelled),
822    }
823}
824
825/// A future that completes at `deadline`, or never if it is `None`.
826///
827/// Shared by the deadline-driven arms of
828/// [`next_event`](StreamHandler::next_event)'s `tokio::select!` (the
829/// per-event timeout and the total-stream deadline). Each arm computes its
830/// [`Option<Instant>`] deadline and hands it here, so this function owns the
831/// single definition of "sleep until the instant, or stay pending forever
832/// when disabled."
833///
834/// `None` disables the arm: the returned future never resolves, so the
835/// `select!` branch stays inert. This is how
836/// [`passthrough`](StreamHandler::passthrough) (which sets every timeout to
837/// [`Duration::MAX`], yielding `None` deadlines) disables resilience without
838/// a separate code path. A `Some(deadline)` already in the past resolves
839/// immediately, letting a lapsed deadline fire on the next poll rather than
840/// being missed.
841async fn deadline_future(deadline: Option<Instant>) {
842    match deadline {
843        Some(deadline) => tokio::time::sleep_until(deadline.into()).await,
844        None => std::future::pending::<()>().await,
845    }
846}
847
848/// Result of polling the stream once inside [`StreamHandler::next_event`].
849///
850/// Produced by the `tokio::select!` that races the stream against the
851/// per-event timeout. Only two outcomes materialize here: the stream produced
852/// an item ([`Next`](Self::Next)), or the per-event timeout fired first
853/// ([`TimedOut`](Self::TimedOut)). Cancellation and the total-stream deadline
854/// are also raced in the same `select!`, but they return directly as
855/// [`StreamHandlerError::Cancelled`] / `StreamFailed` and so do not need a
856/// variant here.
857enum EventPoll {
858    /// The stream produced an item before the per-event timeout.
859    ///
860    /// Delegates the three sub-cases to the caller: `Some(Ok(event))` is
861    /// yielded to the accumulator, `Some(Err(api_error))` becomes an API-error
862    /// outcome, and `None` means the stream ended cleanly (turn completes).
863    Next(Option<Result<crate::stream::StreamEvent, crate::api::error::ApiError>>),
864
865    /// The per-event timeout fired before any item arrived.
866    ///
867    /// Increments the consecutive-timeout counter; once it reaches
868    /// [`max_consecutive_timeouts`](StreamTimeoutConfig::max_consecutive_timeouts),
869    /// the caller escalates to a [`StreamFailed`](StreamHandlerError::StreamFailed)
870    /// event-timeout outcome. A lower threshold (`min(2, max_consecutive_timeouts)`)
871    /// applies when no events have been received yet (empty-stream fast-fail).
872    TimedOut,
873}
874
875/// Read-only diagnostic context used to build timeout and error outcomes.
876///
877/// Snapshotted once per loop iteration in [`StreamHandler::stream_turn`] and
878/// handed to [`StreamHandler::next_event`], which needs progress/elapsed data to
879/// populate [`StreamOutcome`] fields when it short-circuits.
880struct EventDiagnostics {
881    /// Events processed so far this turn.
882    ///
883    /// Surfaced on the [`StreamOutcome::TotalTimeout`] and
884    /// [`StreamOutcome::RateLimited`] outcomes so the caller can tell a
885    /// mid-stream failure (some events got through) from an immediate one
886    /// (nothing arrived). Not used by [`event_timeout`](Self::event_timeout),
887    /// which reports consecutive-timeout count instead.
888    events_processed: u64,
889
890    /// When the stream started, for elapsed-duration outcomes.
891    ///
892    /// Read via [`Instant::elapsed`] when building
893    /// [`StreamOutcome::TotalTimeout`]'s `duration` field. Captured once at
894    /// the top of [`stream_turn`](Self::stream_turn) rather
895    /// than per event so the reported duration is the full stream lifetime,
896    /// not the time since the most recent event.
897    stream_start: Instant,
898
899    /// Whether partial content has been accumulated.
900    ///
901    /// Recomputed each loop iteration (the whole [`EventDiagnostics`] is
902    /// rebuilt per iteration in [`stream_turn`](Self::stream_turn))
903    /// from the accumulator's current part count: `true` once at least one
904    /// usable event has been received. Flows into the `has_partial_data` flag
905    /// on [`StreamOutcome::TotalTimeout`], [`StreamOutcome::EventTimeout`],
906    /// and [`StreamOutcome::RateLimited`], letting a downstream consumer
907    /// decide whether to salvage the partial output or discard it.
908    has_partial_data: bool,
909
910    /// Stream attempts started so far this turn, counting both ladders.
911    ///
912    /// `transport_attempts + rate_limit_retries + 1` — the `+1` is the
913    /// attempt in flight when this snapshot was taken. Mid-stream failures
914    /// report it as [`StreamOutcome::InitFailed`]'s `attempts` so the
915    /// count reflects every request the turn has issued, including the
916    /// rate-limit ladder's backoffs (which never increment the transport
917    /// counter).
918    attempts_so_far: u32,
919}
920
921impl EventDiagnostics {
922    /// Snapshot the diagnostic context for one event poll.
923    ///
924    /// Convenience constructor for the per-iteration snapshot
925    /// [`next_event`](StreamHandler::next_event) receives: progress counts,
926    /// the turn-level start instant, partial-data presence derived from
927    /// the shadow accumulator's current part list, and the attempts
928    /// started so far this turn (both retry ladders counted, plus the
929    /// in-flight attempt).
930    fn new(
931        events_processed: u64,
932        stream_start: Instant,
933        shadow: &StreamAccumulator,
934        attempts_so_far: u32,
935    ) -> Self {
936        Self {
937            events_processed,
938            stream_start,
939            has_partial_data: !shadow.peek_parts().is_empty(),
940            attempts_so_far,
941        }
942    }
943
944    /// Build the [`StreamOutcome::TotalTimeout`] for this point in the stream.
945    ///
946    /// Snapshots the current diagnostic state — partial-data flag, events
947    /// processed so far, and elapsed time since [`stream_start`](Self::stream_start)
948    /// — into a `TotalTimeout` outcome. Used by [`stream_turn`](Self::stream_turn)
949    /// when the turn's `total_stream_timeout` deadline fires (both at the
950    /// top-of-loop check and inside the per-event `select!`).
951    fn total_timeout(&self) -> StreamOutcome {
952        StreamOutcome::TotalTimeout {
953            has_partial_data: self.has_partial_data,
954            events_processed: self.events_processed,
955            duration: self.stream_start.elapsed(),
956        }
957    }
958
959    /// Build the [`StreamOutcome::EventTimeout`] for this point in the stream.
960    ///
961    /// Carries the partial-data flag plus the caller-supplied
962    /// `consecutive_timeouts` count (this method does not track the counter
963    /// itself — `stream_turn` owns it and passes the current value in).
964    /// Used once the per-event timeout crosses
965    /// [`max_consecutive_timeouts`](StreamTimeoutConfig::max_consecutive_timeouts).
966    fn event_timeout(&self, consecutive_timeouts: u32) -> StreamOutcome {
967        StreamOutcome::EventTimeout {
968            has_partial_data: self.has_partial_data,
969            consecutive_timeouts,
970        }
971    }
972
973    /// Map a stream API error to the matching [`StreamFailure`].
974    ///
975    /// Two branches: if [`DetectedRateLimit::detect`] classifies the error as
976    /// a 429/503/529, builds a [`StreamOutcome::RateLimited`] carrying the
977    /// parsed `Retry-After` and current progress; otherwise wraps it as a
978    /// generic [`StreamOutcome::InitFailed`] whose `attempts` is
979    /// [`attempts_so_far`](Self::attempts_so_far) — every request the turn
980    /// has issued, both ladders counted. The retry verdict is
981    /// [`ApiError::is_retryable`] consulted while the error is still typed —
982    /// the outcome flattens it to a message string, after which the
983    /// classification would be unrecoverable. Used by `stream_turn` when the
984    /// stream yields an `Err` event.
985    fn api_error_failure(&self, error: &crate::api::error::ApiError) -> StreamFailure {
986        let retryable = error.is_retryable();
987        if let Some(detail) = DetectedRateLimit::detect(error) {
988            return StreamFailure {
989                error: StreamHandlerError::StreamFailed(StreamOutcome::RateLimited {
990                    detail,
991                    has_partial_data: self.has_partial_data,
992                    events_processed: self.events_processed,
993                }),
994                retryable,
995            };
996        }
997        StreamFailure {
998            error: StreamHandlerError::StreamFailed(StreamOutcome::InitFailed {
999                attempts: self.attempts_so_far,
1000                last_error: error.to_string(),
1001            }),
1002            retryable,
1003        }
1004    }
1005}
1006
1007/// Why the stream ended.
1008///
1009/// Each variant captures the relevant context for how streaming
1010/// terminated. This allows callers to make informed decisions about
1011/// whether to retry, use partial data, or report an error.
1012///
1013/// # Ordering
1014///
1015/// The variants are ordered by severity:
1016///
1017/// ```text
1018/// Completed < TotalTimeout < EventTimeout < RateLimited < InitFailed < FallbackToNonStreaming < Cancelled
1019/// ```
1020#[derive(Debug, Clone)]
1021#[non_exhaustive]
1022pub enum StreamOutcome {
1023    /// Stream completed normally — all events received, `MessageStop` seen.
1024    ///
1025    /// Happy path. The [`StreamAccumulator`]
1026    /// contains the full response.
1027    Completed {
1028        /// Number of SSE events processed before `MessageStop`.
1029        ///
1030        /// Counts every event the accumulator accepted, including
1031        /// keep-alive heartbeats and metadata events. Useful as a
1032        /// throughput signal alongside `duration`.
1033        events_processed: u64,
1034
1035        /// Wall-clock duration of the stream from first byte to
1036        /// `MessageStop`.
1037        ///
1038        /// Measured inside the handler; divide `events_processed` by
1039        /// this to get the average events-per-second rate for
1040        /// telemetry.
1041        duration: Duration,
1042    },
1043
1044    /// Total-stream timeout exceeded.
1045    ///
1046    /// The stream was active for longer than
1047    /// [`total_stream_timeout`](StreamTimeoutConfig::total_stream_timeout).
1048    /// Partial data may be available in the accumulator.
1049    TotalTimeout {
1050        /// Whether partial content was accumulated before the timeout.
1051        ///
1052        /// `true` when at least one content event arrived before the
1053        /// deadline; the caller may inspect the accumulator to decide
1054        /// whether the partial result is usable.
1055        has_partial_data: bool,
1056
1057        /// Events processed before the timeout fired.
1058        ///
1059        /// How many SSE events the accumulator accepted before the
1060        /// overall stream deadline elapsed — zero implies the stream
1061        /// stalled immediately.
1062        events_processed: u64,
1063
1064        /// Elapsed time from stream start to the timeout trigger.
1065        ///
1066        /// Approximately equal to
1067        /// [`total_stream_timeout`](StreamTimeoutConfig::total_stream_timeout),
1068        /// reported for diagnostics so callers can correlate the
1069        /// observed wait with the configured ceiling.
1070        duration: Duration,
1071    },
1072
1073    /// Per-event timeouts exhausted.
1074    ///
1075    /// Too many consecutive events failed to arrive within
1076    /// [`per_event_timeout`](StreamTimeoutConfig::per_event_timeout).
1077    /// Partial data may be available.
1078    EventTimeout {
1079        /// Whether partial content was accumulated before the failure.
1080        ///
1081        /// `true` when at least one content event arrived before the
1082        /// consecutive-timeout threshold was reached; the caller may
1083        /// inspect the accumulator to decide whether the partial result
1084        /// is usable.
1085        has_partial_data: bool,
1086
1087        /// Consecutive per-event timeouts that triggered the failure.
1088        ///
1089        /// Reaches
1090        /// [`max_consecutive_timeouts`](StreamTimeoutConfig::max_consecutive_timeouts)
1091        /// when the handler gives up. Each individual timeout equals
1092        /// [`per_event_timeout`](StreamTimeoutConfig::per_event_timeout);
1093        /// this count tells the caller how many gaps were observed.
1094        consecutive_timeouts: u32,
1095    },
1096
1097    /// A 429 / 503 rate-limit response arrived mid-stream.
1098    ///
1099    /// Distinct from [`InitFailed`](Self::InitFailed): the stream *was*
1100    /// established and may have produced partial output. The
1101    /// [`DetectedRateLimit`] carries the parsed `Retry-After`, if the server
1102    /// provided one.
1103    RateLimited {
1104        /// Decoded rate-limit detail.
1105        ///
1106        /// Carries the rate-limit kind (429 / 503 / 529) and the parsed
1107        /// `Retry-After` hint, if the server provided one. Downstream
1108        /// layers (the fallback manager, the retry loop) read this to
1109        /// honour the provider's back-off guidance without re-parsing
1110        /// the response.
1111        detail: DetectedRateLimit,
1112
1113        /// Whether partial content was accumulated before the rate limit.
1114        ///
1115        /// `true` when the stream produced content events before the
1116        /// provider started rate-limiting; the caller may inspect the
1117        /// accumulator to decide whether the partial result is usable.
1118        has_partial_data: bool,
1119
1120        /// Events processed before the rate limit fired.
1121        ///
1122        /// How many SSE events the accumulator accepted before the
1123        /// 429/503/529 response arrived — zero implies the provider
1124        /// rejected the stream early.
1125        events_processed: u64,
1126    },
1127
1128    /// The stream failed before completing — the name is historical.
1129    ///
1130    /// Covers both a true initialization failure (no first event from
1131    /// any retry attempt) and, despite the name, mid-stream failures
1132    /// surfaced by the event loop (API error events, malformed
1133    /// accumulator events): those may have already delivered partial
1134    /// data to the consumer, which the retry caveat on
1135    /// [`on_text_delta`](crate::observer::LoopObserver::on_text_delta)
1136    /// documents. The `attempts` field counts every request the turn
1137    /// issued, both retry ladders included.
1138    InitFailed {
1139        /// The last error from the final retry attempt.
1140        ///
1141        /// Rendered to a string for diagnostics; typically the
1142        /// underlying transport or HTTP error that prevented the
1143        /// handler from receiving a first event. Surface this in logs
1144        /// so the caller can see why the stream never started.
1145        last_error: String,
1146
1147        /// Number of retry attempts made before giving up.
1148        ///
1149        /// Counts the (re-)connection attempts up to
1150        /// [`StreamRetryConfig`]'s ceiling; reaching this count without
1151        /// a first event means the provider was unreachable or
1152        /// rejecting the request outright.
1153        attempts: u32,
1154    },
1155
1156    /// Fell back to non-streaming [`create_message`](crate::api::ApiClient::create_message).
1157    ///
1158    /// Streaming failed, but a non-streaming request succeeded.
1159    /// The response is complete but was not streamed incrementally.
1160    FallbackToNonStreaming,
1161
1162    /// Cancelled by the user via [`CancelSignal`].
1163    ///
1164    /// The stream was terminated because the user requested cancellation.
1165    /// Partial data may be available.
1166    Cancelled,
1167}
1168
1169impl fmt::Display for StreamOutcome {
1170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1171        match self {
1172            Self::Completed {
1173                events_processed,
1174                duration,
1175            } => {
1176                write!(
1177                    f,
1178                    "stream completed ({events_processed} events in {:.1}s)",
1179                    duration.as_secs_f64()
1180                )
1181            }
1182            Self::TotalTimeout {
1183                has_partial_data,
1184                events_processed,
1185                duration,
1186            } => {
1187                let partial = if *has_partial_data {
1188                    " (partial data)"
1189                } else {
1190                    ""
1191                };
1192                write!(
1193                    f,
1194                    "total timeout after {:.1}s, {events_processed} events{partial}",
1195                    duration.as_secs_f64()
1196                )
1197            }
1198            Self::EventTimeout {
1199                has_partial_data,
1200                consecutive_timeouts,
1201            } => {
1202                let partial = if *has_partial_data {
1203                    " (partial data)"
1204                } else {
1205                    ""
1206                };
1207                write!(
1208                    f,
1209                    "event timeout after {consecutive_timeouts} consecutive timeouts{partial}"
1210                )
1211            }
1212            Self::RateLimited {
1213                detail,
1214                has_partial_data,
1215                events_processed,
1216            } => {
1217                let kind = match detail.kind {
1218                    RateLimitKind::RateLimited => "rate limit",
1219                    RateLimitKind::Overloaded => "overloaded",
1220                };
1221                let retry = detail
1222                    .retry_after
1223                    .map(|d| format!(" (retry after {d:?})"))
1224                    .unwrap_or_default();
1225                let partial = if *has_partial_data {
1226                    " (partial data)"
1227                } else {
1228                    ""
1229                };
1230                write!(
1231                    f,
1232                    "{kind}{retry}{partial}, {events_processed} events processed"
1233                )
1234            }
1235            Self::InitFailed {
1236                last_error,
1237                attempts,
1238            } => {
1239                write!(
1240                    f,
1241                    "stream failed before completing after {attempts} attempts: {last_error}"
1242                )
1243            }
1244            Self::FallbackToNonStreaming => {
1245                write!(f, "fell back to non-streaming request")
1246            }
1247            Self::Cancelled => write!(f, "cancelled"),
1248        }
1249    }
1250}
1251
1252/// Errors produced by [`StreamHandler`].
1253///
1254/// Each variant captures the specific failure mode, allowing callers
1255/// to distinguish between transient failures (retryable) and permanent
1256/// errors (non-retryable).
1257#[derive(Debug)]
1258#[non_exhaustive]
1259pub enum StreamHandlerError {
1260    /// The stream failed before completing — the name is historical.
1261    ///
1262    /// Covers initialization failures and mid-stream failures alike;
1263    /// partial data may have been delivered to the consumer (see the
1264    /// [`StreamOutcome`] and the retry caveat on
1265    /// [`on_text_delta`](crate::observer::LoopObserver::on_text_delta)).
1266    /// The outcome carries the last error and the attempt count.
1267    InitFailed(StreamOutcome),
1268
1269    /// Streaming failed mid-stream.
1270    ///
1271    /// Some data may have been accumulated. The [`StreamOutcome`]
1272    /// describes the specific failure mode (timeout, error, etc.).
1273    StreamFailed(StreamOutcome),
1274
1275    /// Both streaming and non-streaming fallback failed.
1276    ///
1277    /// The handler attempted a fallback `create_message()` call after
1278    /// streaming failed, but the fallback also produced an error.
1279    FallbackFailed {
1280        /// The streaming failure that triggered the fallback attempt.
1281        ///
1282        /// Preserved verbatim so the caller can see both halves of the
1283        /// double failure — why streaming gave up (timeout, mid-stream
1284        /// error, rate limit) and why the non-streaming retry then
1285        /// failed — without losing the original context.
1286        stream_outcome: StreamOutcome,
1287
1288        /// The error returned by the non-streaming fallback request.
1289        ///
1290        /// Rendered to a string for diagnostics; typically the
1291        /// underlying transport or HTTP error from the
1292        /// `create_message()` call. Surface both this and
1293        /// `stream_outcome` in logs so the caller can see the full
1294        /// chain of failures.
1295        fallback_error: String,
1296    },
1297
1298    /// The operation was cancelled.
1299    ///
1300    /// The [`CancelSignal`] was triggered
1301    /// before the stream completed. Partial data may be available.
1302    Cancelled,
1303
1304    /// A mutex protecting pacing or routing state was found poisoned.
1305    ///
1306    /// Carries the subsystem label (e.g. `"rate_limit"`). Pacing or
1307    /// routing decisions cannot be made safely from desynchronised
1308    /// state; the caller must surface this rather than continue.
1309    Poisoned(&'static str),
1310
1311    /// Rate-limit retries on the current model were exhausted.
1312    ///
1313    /// The handler honored the provider's `Retry-After` up to the configured
1314    /// [`RateLimitConfig::fallback_after_retries`] ceiling and could not make
1315    /// progress on this model. The caller should escalate to the model circuit
1316    /// breaker ([`FallbackManager`](crate::fallback::FallbackManager)), not the
1317    /// same-model non-streaming fallback.
1318    RateLimitEscalation {
1319        /// Number of rate-limit retries honored before escalating.
1320        ///
1321        /// Counts the 429/503/529 responses the handler retried
1322        /// (honoring the provider's `Retry-After`) before giving up on
1323        /// the current model and handing control to the model circuit
1324        /// breaker. Reaching
1325        /// [`RateLimitConfig::fallback_after_retries`] triggers this
1326        /// variant.
1327        attempts: u32,
1328
1329        /// Last server-advised `Retry-After` hint, after clamping.
1330        ///
1331        /// Preserved for diagnostics and back-off tuning so the caller
1332        /// can correlate the escalation with the provider's last
1333        /// guidance. `None` when the provider sent no `Retry-After`
1334        /// header on the final rate-limited response.
1335        retry_after: Option<Duration>,
1336    },
1337}
1338
1339impl fmt::Display for StreamHandlerError {
1340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1341        match self {
1342            Self::InitFailed(outcome) => write!(f, "stream failed before completing: {outcome}"),
1343            Self::StreamFailed(outcome) => write!(f, "stream failed: {outcome}"),
1344            Self::FallbackFailed {
1345                stream_outcome,
1346                fallback_error,
1347            } => {
1348                write!(
1349                    f,
1350                    "stream failed ({stream_outcome}) and fallback also failed: {fallback_error}"
1351                )
1352            }
1353            Self::Cancelled => write!(f, "cancelled"),
1354            Self::Poisoned(what) => write!(f, "lock poisoned: {what}"),
1355            Self::RateLimitEscalation {
1356                attempts,
1357                retry_after,
1358            } => write!(
1359                f,
1360                "rate-limit escalation after {attempts} retries (retry-after {retry_after:?})"
1361            ),
1362        }
1363    }
1364}
1365
1366impl std::error::Error for StreamHandlerError {}
1367
1368/// Holds configuration for the streaming resilience layer.
1369///
1370/// `StreamHandler` wraps an [`ApiClient`]'s streaming path with timeout,
1371/// retry, and rate-limit handling. It owns four independent budgets that
1372/// together make a stream turn robust:
1373///
1374/// 1. **Timeouts** ([`StreamTimeoutConfig`]) — initial-event, per-event, and
1375///    total-stream deadlines, plus the consecutive-timeout escalation
1376///    threshold and the optional non-streaming fallback.
1377///
1378/// 2. **Transport retries** ([`StreamRetryConfig`]) — exponential backoff for
1379///    stream-initialization failures (connection drops, transport errors),
1380///    distinct from rate-limit retries.
1381///
1382/// 3. **Rate-limit handling** ([`RateLimitConfig`]) — `Retry-After`-aware
1383///    backoff for 429/503/529 responses, with its own retry budget, an
1384///    escalation threshold to the model circuit breaker, and a hard stop.
1385///    Kept independent from transport retries so a rate-limit storm cannot
1386///    exhaust the transport budget (nor vice versa).
1387///
1388/// 4. **Proactive throttling** (optional
1389///    [`RateLimiter`](crate::stream::rate_limit::RateLimiter)) — a per-provider
1390///    token bucket that gates each stream attempt *before* it fires, sleeping
1391///    up to `max_wait` rather than risking a 429.
1392///
1393/// On exhaustion, the handler escalates: rate-limit retries trip the model
1394/// circuit breaker (route to a fallback model), transport retries fall back
1395/// to [`ApiClient::create_message`] when
1396/// [`fallback_to_non_streaming`](StreamTimeoutConfig::fallback_to_non_streaming)
1397/// is set, and a turn that can't recover fails with a typed
1398/// [`StreamHandlerError`].
1399///
1400/// # Example
1401///
1402/// ```rust
1403/// use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig};
1404///
1405/// let handler = StreamHandler::new();
1406/// assert_eq!(handler.timeout_config().initial_event_timeout, std::time::Duration::from_secs(120));
1407///
1408/// let handler = StreamHandler::new().with_timeout_config(
1409///     StreamTimeoutConfig {
1410///         initial_event_timeout: std::time::Duration::from_secs(60),
1411///         ..Default::default()
1412///     },
1413/// );
1414/// assert_eq!(handler.timeout_config().initial_event_timeout, std::time::Duration::from_secs(60));
1415/// ```
1416pub struct StreamHandler {
1417    /// Timeout configuration for all phases.
1418    ///
1419    /// Drives the initial-event / per-event / total-stream deadlines, the
1420    /// consecutive-timeout escalation threshold, and the
1421    /// non-streaming-fallback toggle. Read on every turn in
1422    /// [`stream_turn`](Self::stream_turn) and on each event poll.
1423    timeout_config: StreamTimeoutConfig,
1424
1425    /// Retry configuration for stream-initialization failures.
1426    ///
1427    /// Exponential backoff applied to transport-level failures (connection
1428    /// drops, TLS errors, etc.) before any event arrives. Read in the
1429    /// init-retry loop; distinct from `rate_limit_config`, which has its own
1430    /// budget.
1431    retry_config: StreamRetryConfig,
1432
1433    /// Rate-limit detection + backoff policy.
1434    ///
1435    /// Governs reactive handling of server-returned 429/503/529 responses
1436    /// (honoured `Retry-After`, default delay, cap, escalation threshold to
1437    /// the model circuit breaker, hard-stop ceiling). Read by the
1438    /// `rate_limit_retry` decision on each detected rate limit.
1439    rate_limit_config: RateLimitConfig,
1440
1441    /// Optional proactive per-provider rate limiter (token bucket).
1442    ///
1443    /// When set, each stream attempt is gated by `gate_on_rate_limit` *before*
1444    /// firing — sleeping up to the limiter's `max_wait` for a token rather
1445    /// than risking a 429. `None` (the default) means reactive-only handling:
1446    /// no pre-throttling, server 429s still handled via `rate_limit_config`.
1447    rate_limiter: Option<Arc<crate::stream::rate_limit::RateLimiter>>,
1448
1449    /// Upper bound on how long `gate_on_rate_limit` blocks for a token.
1450    ///
1451    /// When the limiter's `acquire` returns a wait exceeding this, the
1452    /// handler proceeds anyway rather than hanging the agent. Defaults
1453    /// to 30 seconds.
1454    rate_limit_max_wait: Duration,
1455}
1456
1457impl fmt::Debug for StreamHandler {
1458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1459        f.debug_struct("StreamHandler")
1460            .field("timeout_config", &self.timeout_config)
1461            .field("retry_config", &self.retry_config)
1462            .field("rate_limit_config", &self.rate_limit_config)
1463            .field("rate_limiter", &self.rate_limiter)
1464            .field("rate_limit_max_wait", &self.rate_limit_max_wait)
1465            .finish()
1466    }
1467}
1468
1469impl Default for StreamHandler {
1470    fn default() -> Self {
1471        Self::new()
1472    }
1473}
1474
1475impl StreamHandler {
1476    /// Create a no-resilience handler — yields the raw provider stream with
1477    /// no retries, no timeouts, no fallback, no rate-limit handling.
1478    ///
1479    /// Used as the default when no `StreamHandler` is configured: the engine
1480    /// always routes streaming through a handler, and `passthrough()` is what
1481    /// you get when you haven't opted into resilience. Behaves like the
1482    /// pre-redesign inline streaming path — one attempt, no retry, surface
1483    /// the underlying [`ApiClient`] errors directly.
1484    ///
1485    /// Also useful as an explicit baseline for callers who want to start
1486    /// fresh and reconfigure only the fields they care about:
1487    ///
1488    /// ```rust,no_run
1489    /// # use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig};
1490    /// let handler = StreamHandler::passthrough()
1491    ///     .with_timeout_config(
1492    ///         StreamTimeoutConfig {
1493    ///             total_stream_timeout: std::time::Duration::from_secs(60),
1494    ///             ..Default::default()
1495    ///         },
1496    ///     );
1497    /// ```
1498    ///
1499    /// Returned by [`passthrough_default`](Self::passthrough_default) as a
1500    /// shared static, so [`StreamCapable::stream_handler`](crate::capabilities::StreamCapable::stream_handler)
1501    /// can return `&Self` (never `Option<&Self>`).
1502    #[must_use]
1503    pub fn passthrough() -> Self {
1504        // Duration::MAX overflows Instant::now() + it; stream_turn maps that
1505        // overflow to None (no deadline), so this never fires spuriously.
1506        const NEVER_TIME_OUT: Duration = Duration::MAX;
1507        Self {
1508            timeout_config: StreamTimeoutConfig {
1509                initial_event_timeout: NEVER_TIME_OUT,
1510                per_event_timeout: NEVER_TIME_OUT,
1511                total_stream_timeout: NEVER_TIME_OUT,
1512                // validate() rejects 0; value is irrelevant since timeouts never fire.
1513                max_consecutive_timeouts: 1,
1514                fallback_to_non_streaming: false,
1515            },
1516            retry_config: StreamRetryConfig {
1517                max_retries: 0,
1518                ..Default::default()
1519            },
1520            rate_limit_config: RateLimitConfig {
1521                max_retries: 0,
1522                fallback_after_retries: 0,
1523                ..Default::default()
1524            },
1525            rate_limiter: None,
1526            rate_limit_max_wait: Duration::from_secs(30),
1527        }
1528    }
1529
1530    /// Return a shared reference to the default passthrough handler.
1531    ///
1532    /// Constructed lazily on first access (via `std::sync::OnceLock`) and
1533    /// shared by all callers that don't configure their own [`StreamHandler`].
1534    /// Used by
1535    /// [`StreamCapable::stream_handler`](crate::capabilities::StreamCapable::stream_handler)
1536    /// to always return `&Self` regardless of configuration.
1537    #[must_use]
1538    pub fn passthrough_default() -> &'static Self {
1539        static PASSTHROUGH: std::sync::OnceLock<StreamHandler> = std::sync::OnceLock::new();
1540        PASSTHROUGH.get_or_init(Self::passthrough)
1541    }
1542
1543    /// Create a handler with default configuration.
1544    ///
1545    /// The defaults are suitable for production LLM API usage:
1546    /// - 120s initial event timeout
1547    /// - 180s per-event timeout
1548    /// - 300s total stream timeout
1549    /// - 3 retries with 100ms base delay
1550    ///
1551    /// # Example
1552    ///
1553    /// ```rust
1554    /// use loopctl::stream::handler::StreamHandler;
1555    ///
1556    /// let handler = StreamHandler::new();
1557    /// ```
1558    #[must_use]
1559    pub fn new() -> Self {
1560        Self {
1561            timeout_config: StreamTimeoutConfig::default(),
1562            retry_config: StreamRetryConfig::default(),
1563            rate_limit_config: RateLimitConfig::default(),
1564            rate_limiter: None,
1565            rate_limit_max_wait: Duration::from_secs(30),
1566        }
1567    }
1568
1569    /// Set the timeout configuration, consuming `self`.
1570    ///
1571    /// Each field that violates a [`validate`](StreamTimeoutConfig::validate)
1572    /// constraint is substituted with the default's value and named in a
1573    /// warning; the caller's valid fields are kept, and the sanitized
1574    /// result always satisfies every `validate` rule. Constructing the
1575    /// config directly bypasses this — call
1576    /// [`validate`](StreamTimeoutConfig::validate) on hand-built configs.
1577    ///
1578    /// # Example
1579    ///
1580    /// ```rust
1581    /// use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig};
1582    /// use std::time::Duration;
1583    ///
1584    /// let handler = StreamHandler::new().with_timeout_config(
1585    ///     StreamTimeoutConfig {
1586    ///         initial_event_timeout: Duration::from_secs(60),
1587    ///         ..Default::default()
1588    ///     },
1589    /// );
1590    /// ```
1591    #[must_use]
1592    pub fn with_timeout_config(mut self, timeout: StreamTimeoutConfig) -> Self {
1593        self.timeout_config = Self::sanitized_timeout_config(timeout);
1594        self
1595    }
1596
1597    /// Repair an invalid [`StreamTimeoutConfig`] field by field.
1598    ///
1599    /// Each field that violates a [`validate`](StreamTimeoutConfig::validate)
1600    /// constraint — including infinite (`Duration::MAX`) event timeouts,
1601    /// which silently disable the deadline they name — is substituted
1602    /// with the default's value and named in a warning; every valid
1603    /// field the caller supplied is kept. The ordering rule runs once
1604    /// after substitution: a `total_stream_timeout` below the (possibly
1605    /// sanitized) `initial_event_timeout` is raised to it, so the
1606    /// sanitized result always satisfies every `validate` rule.
1607    /// Constructing the config directly bypasses this — call
1608    /// [`validate`](StreamTimeoutConfig::validate) yourself on hand-built
1609    /// configs.
1610    fn sanitized_timeout_config(timeout: StreamTimeoutConfig) -> StreamTimeoutConfig {
1611        let default = StreamTimeoutConfig::default();
1612        let mut sanitized = timeout;
1613        let mut repaired: Vec<&'static str> = Vec::new();
1614
1615        if sanitized.initial_event_timeout.is_zero()
1616            || sanitized.initial_event_timeout == Duration::MAX
1617        {
1618            sanitized.initial_event_timeout = default.initial_event_timeout;
1619            repaired.push("initial_event_timeout");
1620        }
1621        if sanitized.per_event_timeout.is_zero() || sanitized.per_event_timeout == Duration::MAX {
1622            sanitized.per_event_timeout = default.per_event_timeout;
1623            repaired.push("per_event_timeout");
1624        }
1625        if sanitized.total_stream_timeout.is_zero()
1626            || sanitized.total_stream_timeout == Duration::MAX
1627        {
1628            sanitized.total_stream_timeout = default.total_stream_timeout;
1629            repaired.push("total_stream_timeout");
1630        }
1631        if sanitized.total_stream_timeout < sanitized.initial_event_timeout {
1632            sanitized.total_stream_timeout = sanitized.initial_event_timeout;
1633            repaired.push("total_stream_timeout");
1634        }
1635        if sanitized.max_consecutive_timeouts == 0 {
1636            sanitized.max_consecutive_timeouts = default.max_consecutive_timeouts;
1637            repaired.push("max_consecutive_timeouts");
1638        }
1639        if !repaired.is_empty() {
1640            tracing::warn!(
1641                fields = repaired.join(","),
1642                "invalid StreamTimeoutConfig fields substituted with defaults"
1643            );
1644        }
1645        sanitized
1646    }
1647
1648    /// Set the retry configuration, consuming `self`.
1649    ///
1650    /// Validates `retry`: if it violates any constraint, the invalid
1651    /// value is logged and the previously configured (initially default)
1652    /// config is kept instead. See
1653    /// [`StreamRetryConfig::validate`] for the constraints enforced.
1654    ///
1655    /// # Example
1656    ///
1657    /// ```rust
1658    /// use loopctl::stream::handler::{StreamHandler, StreamRetryConfig};
1659    ///
1660    /// let handler = StreamHandler::new().with_retry_config(
1661    ///     StreamRetryConfig {
1662    ///         max_retries: 5,
1663    ///         ..Default::default()
1664    ///     },
1665    /// );
1666    /// ```
1667    #[must_use]
1668    pub fn with_retry_config(mut self, retry: StreamRetryConfig) -> Self {
1669        if let Err(e) = retry.validate() {
1670            tracing::warn!(error = %e, "invalid StreamRetryConfig, falling back to default");
1671        } else {
1672            self.retry_config = retry;
1673        }
1674        self
1675    }
1676
1677    /// Returns a reference to the timeout configuration.
1678    ///
1679    /// Read-only access to the [`StreamTimeoutConfig`] stored on the handler.
1680    /// Mutate via
1681    /// [`with_timeout_config`](Self::with_timeout_config).
1682    #[must_use]
1683    pub fn timeout_config(&self) -> &StreamTimeoutConfig {
1684        &self.timeout_config
1685    }
1686
1687    /// Returns a reference to the retry configuration.
1688    ///
1689    /// Read-only access to the [`StreamRetryConfig`] stored on the handler.
1690    /// Mutate via [`with_retry_config`](Self::with_retry_config).
1691    #[must_use]
1692    pub fn retry_config(&self) -> &StreamRetryConfig {
1693        &self.retry_config
1694    }
1695
1696    /// Set a custom [`RateLimitConfig`]. Consuming builder.
1697    ///
1698    /// Validates the config: if it violates any constraint (zero delay,
1699    /// inverted ceilings, etc.), the invalid value is logged and the
1700    /// default config is used instead. This prevents silently storing a
1701    /// config that would invert retry/escalation behavior.
1702    ///
1703    /// # Example
1704    ///
1705    /// ```
1706    /// use loopctl::stream::handler::{StreamHandler, RateLimitConfig};
1707    ///
1708    /// let handler = StreamHandler::new().with_rate_limit_config(
1709    ///     RateLimitConfig { max_retries: 5, fallback_after_retries: 2, ..Default::default() },
1710    /// );
1711    /// assert_eq!(handler.rate_limit_config().max_retries, 5);
1712    /// ```
1713    #[must_use]
1714    pub fn with_rate_limit_config(mut self, rl: RateLimitConfig) -> Self {
1715        if let Err(e) = rl.validate() {
1716            tracing::warn!(error = %e, "invalid RateLimitConfig, falling back to default");
1717            return self;
1718        }
1719        self.rate_limit_config = rl;
1720        self
1721    }
1722
1723    /// Returns a reference to the rate-limit configuration.
1724    ///
1725    /// Read-only access to the [`RateLimitConfig`] stored on the handler.
1726    /// Mutate via
1727    /// [`with_rate_limit_config`](StreamHandler::with_rate_limit_config).
1728    #[must_use]
1729    pub fn rate_limit_config(&self) -> &RateLimitConfig {
1730        &self.rate_limit_config
1731    }
1732
1733    /// Attach a per-provider rate limiter (proactive throttle).
1734    ///
1735    /// When set, every `stream_turn` attempt waits for a token before
1736    /// opening the stream, spacing requests to the limiter's `requests_per_minute`
1737    /// ceiling. `None` (the default) disables proactive throttling — the
1738    /// handler then relies purely on the reactive 429 handling.
1739    ///
1740    /// # Example
1741    ///
1742    /// ```rust
1743    /// use std::sync::Arc;
1744    /// use loopctl::stream::handler::StreamHandler;
1745    /// use loopctl::stream::rate_limit::RateLimiter;
1746    ///
1747    /// let handler = StreamHandler::new()
1748    ///     .with_rate_limiter(Arc::new(RateLimiter::new(60)));
1749    /// ```
1750    #[must_use]
1751    pub fn with_rate_limiter(
1752        mut self,
1753        limiter: Arc<crate::stream::rate_limit::RateLimiter>,
1754    ) -> Self {
1755        self.rate_limiter = Some(limiter);
1756        self
1757    }
1758
1759    /// Set the max-wait ceiling for `gate_on_rate_limit` (builder style).
1760    ///
1761    /// Defaults to 30 seconds. When the limiter's `acquire` returns a
1762    /// wait exceeding this, the handler proceeds anyway rather than
1763    /// hanging the agent.
1764    #[must_use]
1765    pub fn with_rate_limit_max_wait(mut self, max_wait: Duration) -> Self {
1766        self.rate_limit_max_wait = max_wait;
1767        self
1768    }
1769
1770    /// Drive one turn as a stream of [`HandlerEvent`]s.
1771    ///
1772    /// The engine consumes this stream directly: real stream events flow to
1773    /// observers and the engine's accumulator (identical to the inline
1774    /// streaming path), [`HandlerEvent::AttemptReset`] signals a retry so the
1775    /// engine can discard partial state, and [`HandlerEvent::Fallback`]
1776    /// delivers the non-streaming fallback message when retries exhaust.
1777    ///
1778    /// The handler keeps its retry/rate-limit/timeout/fallback machinery —
1779    /// this method is the retry loop reshaped as a stream. Events from failed
1780    /// attempts are yielded as `HandlerEvent::Stream` to the engine before the
1781    /// attempt fails; the engine then receives `AttemptReset` and discards
1782    /// them. Consumers that want only committed output must reset their state
1783    /// on `AttemptReset`.
1784    ///
1785    /// # Errors
1786    ///
1787    /// Item errors carry the same [`StreamHandlerError`] variants the
1788    /// pre-redesign `stream_turn` returned: cancellation, timeout, transport
1789    /// retry exhaustion, rate-limit escalation, and non-streaming fallback
1790    /// failure.
1791    /// A successful turn ends with `None` from the stream once the
1792    /// provider's terminal `MessageStop` has been seen (or after
1793    /// [`HandlerEvent::Fallback`]); a stream that ends without the
1794    /// terminal event is treated as truncated and routed through the
1795    /// retry ladder like any other mid-stream failure.
1796    pub fn stream_turn<'a, C: ApiClient>(
1797        &'a self,
1798        client: &'a C,
1799        request: &'a crate::api::StreamRequest,
1800        options: crate::structured::RequestOptions,
1801        cancel: &'a Arc<CancelSignal>,
1802    ) -> Pin<Box<dyn Stream<Item = Result<HandlerEvent, StreamHandlerError>> + Send + 'a>> {
1803        let total_deadline = Instant::now().checked_add(self.timeout_config.total_stream_timeout);
1804        let stream_start = Instant::now();
1805        let max_attempts = self.retry_config.max_retries.saturating_add(1);
1806
1807        Box::pin(async_stream::try_stream! {
1808            let mut rate_limit_retries: u32 = 0;
1809            let mut transport_attempts: u32 = 0;
1810            let mut first_attempt = true;
1811            // Shadow accumulator tracks partial-data presence for diagnostics.
1812            let mut shadow = StreamAccumulator::new();
1813
1814            loop {
1815                if !first_attempt {
1816                    shadow = StreamAccumulator::new();
1817                    yield HandlerEvent::AttemptReset;
1818                }
1819                first_attempt = false;
1820
1821                self.gate_on_rate_limit(client, cancel, total_deadline).await?;
1822                let mut stream =
1823                    client.stream_messages_with_options(request, options.clone());
1824
1825                let mut consecutive_timeouts: usize = 0;
1826                let mut events_processed: u64 = 0;
1827                let mut saw_terminal = false;
1828
1829                let action = loop {
1830                    let diagnostics = EventDiagnostics::new(
1831                        events_processed,
1832                        stream_start,
1833                        &shadow,
1834                        transport_attempts
1835                            .saturating_add(rate_limit_retries)
1836                            .saturating_add(1),
1837                    );
1838                    match self
1839                        .next_event(
1840                            &mut stream,
1841                            cancel,
1842                            &mut consecutive_timeouts,
1843                            total_deadline,
1844                            &diagnostics,
1845                        )
1846                        .await
1847                    {
1848                        Ok(Some(event)) => {
1849                            events_processed = events_processed.saturating_add(1);
1850                            consecutive_timeouts = 0;
1851                            if matches!(event, StreamEvent::MessageStop) {
1852                                saw_terminal = true;
1853                            }
1854                            if let Err(failure) =
1855                                Self::accumulate_event(&diagnostics, &mut shadow, &event)
1856                            {
1857                                break self.decide_failure_action(
1858                                    failure,
1859                                    &mut rate_limit_retries,
1860                                    &mut transport_attempts,
1861                                    max_attempts,
1862                                    total_deadline,
1863                                );
1864                            }
1865                            yield HandlerEvent::Stream(event);
1866                        }
1867                        Ok(None) => {
1868                            if saw_terminal {
1869                                return;
1870                            }
1871                            let failure = StreamFailure::transient(
1872                                StreamHandlerError::StreamFailed(StreamOutcome::InitFailed {
1873                                    attempts: diagnostics.attempts_so_far,
1874                                    last_error: format!(
1875                                        "stream ended without a terminal event after \
1876                                         {events_processed} events (truncated?)"
1877                                    ),
1878                                }),
1879                            );
1880                            break self.decide_failure_action(
1881                                failure,
1882                                &mut rate_limit_retries,
1883                                &mut transport_attempts,
1884                                max_attempts,
1885                                total_deadline,
1886                            );
1887                        }
1888                        Err(failure) => break self.decide_failure_action(
1889                            failure,
1890                            &mut rate_limit_retries,
1891                            &mut transport_attempts,
1892                            max_attempts,
1893                            total_deadline,
1894                        ),
1895                    }
1896                };
1897
1898                match action {
1899                    ErrorAction::Fail(e) => {
1900                        Err(e)?;
1901                        return;
1902                    }
1903                    ErrorAction::TryFallback(outcome) => {
1904                        let (message, stop_reason, usage) = self
1905                            .fallback_non_streaming(
1906                                client,
1907                                request,
1908                                &options,
1909                                cancel,
1910                                total_deadline,
1911                                outcome,
1912                            )
1913                            .await?;
1914                        yield HandlerEvent::Fallback {
1915                            message,
1916                            stop_reason,
1917                            usage,
1918                        };
1919                        return;
1920                    }
1921                    ErrorAction::Retry(delay) => {
1922                        sleep_cancellable(delay, cancel).await?;
1923                    }
1924                }
1925            }
1926        })
1927    }
1928
1929    /// Route a failed event poll to the matching retry ladder.
1930    ///
1931    /// The single dispatch point for the generator's error arm: when the
1932    /// failure's carried outcome is [`StreamOutcome::RateLimited`] it draws on
1933    /// the rate-limit budget via
1934    /// [`decide_rate_limit_error`](Self::decide_rate_limit_error); every other
1935    /// failure draws on the transport budget via
1936    /// [`decide_transport_error`](Self::decide_transport_error) (including the
1937    /// retryability fast-fail and the terminal total-timeout route). Keeping
1938    /// the outcome-inspection here leaves the generator body a flat
1939    /// decision-and-act sequence.
1940    fn decide_failure_action(
1941        &self,
1942        failure: StreamFailure,
1943        rate_limit_retries: &mut u32,
1944        transport_attempts: &mut u32,
1945        max_attempts: u32,
1946        total_deadline: Option<Instant>,
1947    ) -> ErrorAction {
1948        let last_stream_outcome = carried_outcome(&failure.error);
1949        if let Some(StreamOutcome::RateLimited { detail, .. }) = &last_stream_outcome {
1950            self.decide_rate_limit_error(
1951                failure.error,
1952                detail,
1953                rate_limit_retries,
1954                total_deadline,
1955                last_stream_outcome.clone(),
1956            )
1957        } else {
1958            self.decide_transport_error(
1959                failure.error,
1960                failure.retryable,
1961                transport_attempts,
1962                max_attempts,
1963                last_stream_outcome.clone(),
1964                total_deadline,
1965            )
1966        }
1967    }
1968
1969    /// Accumulate one accepted event into the shadow accumulator.
1970    ///
1971    /// Wraps [`StreamAccumulator::process`] for the generator's happy-path
1972    /// arm: a malformed event becomes a transient [`StreamFailure`] with the
1973    /// attempt count carried as [`EventDiagnostics::attempts_so_far`], so the
1974    /// generator routes it through [`decide_failure_action`](Self::decide_failure_action)
1975    /// like every other mid-stream failure — it draws on the retry budget
1976    /// and, at exhaustion with the fallback enabled, gets the last-chance
1977    /// non-streaming request instead of failing the turn on first
1978    /// occurrence. Wire-level protocol violations are usually transient
1979    /// (proxy corruption, truncated chunks), and a fresh attempt replays
1980    /// the whole stream.
1981    ///
1982    /// # Errors
1983    ///
1984    /// Returns the wrapped accumulation failure for the generator to hand
1985    /// to the decision ladder.
1986    fn accumulate_event(
1987        diagnostics: &EventDiagnostics,
1988        shadow: &mut StreamAccumulator,
1989        event: &StreamEvent,
1990    ) -> Result<(), StreamFailure> {
1991        shadow.process(event).map_err(|e| {
1992            tracing::warn!(
1993                error = %e,
1994                attempts = diagnostics.attempts_so_far,
1995                events_processed = diagnostics.events_processed,
1996                "malformed accumulator event rejected"
1997            );
1998            StreamFailure::transient(StreamHandlerError::StreamFailed(
1999                StreamOutcome::InitFailed {
2000                    attempts: diagnostics.attempts_so_far,
2001                    last_error: e.to_string(),
2002                },
2003            ))
2004        })
2005    }
2006
2007    /// Decide how to handle a rate-limit stream error.
2008    ///
2009    /// Delegates to [`rate_limit_retry`](Self::rate_limit_retry) for the
2010    /// retry/escalate/hard-stop decision, then maps the result to an
2011    /// [`ErrorAction`] the generator body can act on.
2012    ///
2013    /// `Escalate` fails the turn with
2014    /// [`RateLimitEscalation`](StreamHandlerError::RateLimitEscalation) —
2015    /// under the default config this is the ladder's only terminal outcome.
2016    /// A rate limit is charged against the model's quota, so a same-model
2017    /// non-streaming request is not attempted; the engine records the
2018    /// escalation against the circuit breaker, which routes subsequent
2019    /// turns to a fallback model. `HardStop` (reachable only when
2020    /// `fallback_after_retries == max_retries`; see the
2021    /// [`RateLimitRetry`] docs) behaves differently: when
2022    /// [`fallback_to_non_streaming`](StreamTimeoutConfig::fallback_to_non_streaming)
2023    /// is enabled it returns [`ErrorAction::TryFallback`] instead of
2024    /// failing, so a host that configures the ceiling-equal ladder opts
2025    /// into the last-chance non-streaming request.
2026    fn decide_rate_limit_error(
2027        &self,
2028        err: StreamHandlerError,
2029        detail: &DetectedRateLimit,
2030        rate_limit_retries: &mut u32,
2031        total_deadline: Option<Instant>,
2032        last_outcome: Option<StreamOutcome>,
2033    ) -> ErrorAction {
2034        match self.rate_limit_retry(detail, rate_limit_retries, total_deadline) {
2035            RateLimitRetry::Escalate {
2036                attempts,
2037                retry_after,
2038            } => ErrorAction::Fail(StreamHandlerError::RateLimitEscalation {
2039                attempts,
2040                retry_after,
2041            }),
2042            RateLimitRetry::HardStop => {
2043                if self.timeout_config.fallback_to_non_streaming {
2044                    ErrorAction::TryFallback(last_outcome)
2045                } else {
2046                    ErrorAction::Fail(err)
2047                }
2048            }
2049            RateLimitRetry::Retry(delay) => ErrorAction::Retry(delay),
2050        }
2051    }
2052
2053    /// Decide how to handle a non-rate-limit transport stream error.
2054    ///
2055    /// Fails fast — one attempt, no backoff, no non-streaming fallback — when
2056    /// `retryable` is `false`: the verdict is [`ApiError::is_retryable`]
2057    /// consulted while the provider error was still typed, so permanent
2058    /// classes (authentication rejections, other 4xx the retryable set
2059    /// excludes) never enter the retry math.
2060    ///
2061    /// An outcome carrying [`StreamOutcome::TotalTimeout`] is equally
2062    /// terminal but takes the budget-exhaustion route instead: the total
2063    /// budget is already spent, so a second streaming attempt can only fail
2064    /// against the same expired deadline — the non-streaming fallback runs
2065    /// when one is configured, otherwise the error fails the turn. The
2066    /// already-built outcome (real `events_processed`, real
2067    /// `has_partial_data`) propagates verbatim.
2068    ///
2069    /// For other retryable errors, checks whether the transport-retry budget
2070    /// is exhausted:
2071    ///
2072    /// - **Exhausted + fallback enabled** → [`ErrorAction::TryFallback`]:
2073    ///   the non-streaming path gets one last chance, carrying the stream
2074    ///   outcome for diagnostics if it also fails.
2075    /// - **Exhausted + fallback disabled** → [`ErrorAction::Fail`]:
2076    ///   propagate the error.
2077    /// - **Retries remaining** → [`ErrorAction::Retry`]: sleep for the
2078    ///   jittered backoff (clamped to the total-stream deadline), then
2079    ///   retry. Increments `transport_attempts` so the next call knows
2080    ///   how many attempts have been spent.
2081    fn decide_transport_error(
2082        &self,
2083        err: StreamHandlerError,
2084        retryable: bool,
2085        transport_attempts: &mut u32,
2086        max_attempts: u32,
2087        last_outcome: Option<StreamOutcome>,
2088        total_deadline: Option<Instant>,
2089    ) -> ErrorAction {
2090        if !retryable {
2091            return ErrorAction::Fail(err);
2092        }
2093        if matches!(last_outcome, Some(StreamOutcome::TotalTimeout { .. })) {
2094            if self.timeout_config.fallback_to_non_streaming {
2095                return ErrorAction::TryFallback(last_outcome);
2096            }
2097            return ErrorAction::Fail(err);
2098        }
2099        if *transport_attempts >= max_attempts.saturating_sub(1) {
2100            if self.timeout_config.fallback_to_non_streaming {
2101                return ErrorAction::TryFallback(last_outcome);
2102            }
2103            return ErrorAction::Fail(err);
2104        }
2105        let delay = self.retry_config.jittered_base_delay(*transport_attempts);
2106        let delay = clamp_delay_to_deadline(delay, total_deadline);
2107        *transport_attempts = transport_attempts.saturating_add(1);
2108        ErrorAction::Retry(delay)
2109    }
2110
2111    /// Decide how to handle a rate-limit failure on the current model.
2112    ///
2113    /// Bumps `count` and returns one of:
2114    /// - [`RateLimitRetry::HardStop`] once `count` exceeds
2115    ///   [`max_retries`](RateLimitConfig::max_retries) — the absolute ceiling;
2116    /// - [`RateLimitRetry::Escalate`] once `count` exceeds
2117    ///   [`fallback_after_retries`](RateLimitConfig::fallback_after_retries)
2118    ///   but is still within `max_retries` — the caller escalates to the
2119    ///   model circuit breaker;
2120    /// - [`RateLimitRetry::Retry`] with the deadline-clamped backoff otherwise.
2121    ///
2122    /// `max_retries` is checked first so it is always enforced as the hard
2123    /// ceiling, regardless of `fallback_after_retries`.
2124    fn rate_limit_retry(
2125        &self,
2126        detail: &DetectedRateLimit,
2127        count: &mut u32,
2128        deadline: Option<Instant>,
2129    ) -> RateLimitRetry {
2130        *count = count.saturating_add(1);
2131        if *count > self.rate_limit_config.max_retries {
2132            return RateLimitRetry::HardStop;
2133        }
2134        if *count > self.rate_limit_config.fallback_after_retries {
2135            return RateLimitRetry::Escalate {
2136                attempts: *count,
2137                retry_after: detail.retry_after,
2138            };
2139        }
2140        let delay =
2141            clamp_delay_to_deadline(self.rate_limit_config.backoff(detail.retry_after), deadline);
2142        RateLimitRetry::Retry(delay)
2143    }
2144
2145    /// Attempt a single streaming pass.
2146    ///
2147    /// Gates on the proactive [`RateLimiter`](crate::stream::rate_limit::RateLimiter)
2148    /// (if attached), opens a stream via
2149    /// [`ApiClient::stream_messages_with_options`] carrying the handler's
2150    /// [`RequestOptions`](crate::structured::RequestOptions), and processes
2151    /// all events with timeout and cancellation support. Called inside the
2152    /// retry loop in [`stream_turn`](Self::stream_turn), so a
2153    /// retried 429 re-gates on the rate limiter.
2154    ///
2155    /// Wait for a rate-limit token before opening the stream.
2156    ///
2157    /// When a [`RateLimiter`](crate::stream::rate_limit::RateLimiter) is
2158    /// attached, this acquires one token from the bucket keyed by the client's
2159    /// [`base_url`](ApiClient::base_url), sleeping as needed until either a
2160    /// token is available, the cumulative wait reaches the limiter's `max_wait`
2161    /// (better to risk a 429 than hang the agent). Each wait is also clamped to
2162    /// the turn's remaining `total_deadline` so the gate cannot overrun the
2163    /// turn budget; if the deadline has already elapsed the gate proceeds
2164    /// rather than sleeping (the downstream per-event/total-timeout checks in
2165    /// [`stream_turn`](Self::stream_turn) report the expiry). A no-op
2166    /// when no limiter is attached.
2167    ///
2168    /// Fires per attempt (called from
2169    /// [`stream_turn`](Self::stream_turn), which runs the retry
2170    /// loop), so a retried 429 re-respects the budget.
2171    /// Cancel-safe: a turn stuck waiting for tokens is still user-cancellable.
2172    ///
2173    /// # Errors
2174    ///
2175    /// Returns [`StreamHandlerError::Cancelled`] if the cancel signal fires
2176    /// during the wait.
2177    async fn gate_on_rate_limit<C: ApiClient>(
2178        &self,
2179        client: &C,
2180        cancel: &Arc<CancelSignal>,
2181        total_deadline: Option<Instant>,
2182    ) -> Result<(), StreamHandlerError> {
2183        let Some(limiter) = &self.rate_limiter else {
2184            return Ok(());
2185        };
2186        let key = client.base_url();
2187        let max_wait = self.rate_limit_max_wait;
2188        let mut waited = Duration::ZERO;
2189        loop {
2190            match limiter.acquire(&key) {
2191                Ok(()) => return Ok(()),
2192                Err(rate_limit::RateLimitError::Poisoned) => {
2193                    tracing::warn!("rate-limit bucket poisoned; pacing unavailable");
2194                    return Err(StreamHandlerError::Poisoned("rate_limit"));
2195                }
2196                Err(rate_limit::RateLimitError::Wait(wait)) => {
2197                    if waited >= max_wait {
2198                        return Ok(());
2199                    }
2200                    let max_wait_remaining = max_wait.checked_sub(waited).unwrap_or(Duration::ZERO);
2201                    let total_deadline_remaining = match total_deadline {
2202                        None => max_wait_remaining,
2203                        Some(deadline) => deadline
2204                            .checked_duration_since(Instant::now())
2205                            .unwrap_or(Duration::ZERO),
2206                    };
2207                    let capped = wait.min(max_wait_remaining).min(total_deadline_remaining);
2208                    if capped.is_zero() {
2209                        return Ok(());
2210                    }
2211                    tokio::select! {
2212                        () = tokio::time::sleep(capped) => {}
2213                        () = cancel.notified() => return Err(StreamHandlerError::Cancelled),
2214                    }
2215                    waited = waited.saturating_add(capped);
2216                }
2217            }
2218        }
2219    }
2220
2221    /// Wait for the next stream event, enforcing the total deadline and
2222    /// per-event timeout.
2223    ///
2224    /// Returns `Ok(None)` when the stream ends. On a per-event timeout the
2225    /// consecutive-timeout counter is bumped; once it reaches
2226    /// [`max_consecutive_timeouts`](StreamTimeoutConfig::max_consecutive_timeouts)
2227    /// the turn fails with [`StreamOutcome::EventTimeout`].
2228    ///
2229    /// # Errors
2230    ///
2231    /// Returns a [`StreamFailure`] carrying [`StreamHandlerError::Cancelled`]
2232    /// if the cancel signal fires, or
2233    /// [`StreamHandlerError::StreamFailed`] on total/per-event timeout or an
2234    /// API error — paired with the error's retry classification where the
2235    /// provider error was still typed.
2236    async fn next_event<S>(
2237        &self,
2238        stream: &mut S,
2239        cancel: &Arc<CancelSignal>,
2240        consecutive_timeouts: &mut usize,
2241        total_deadline: Option<Instant>,
2242        diagnostics: &EventDiagnostics,
2243    ) -> Result<Option<StreamEvent>, StreamFailure>
2244    where
2245        S: futures::Stream<Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>>
2246            + Unpin,
2247    {
2248        loop {
2249            if Self::deadline_exceeded(total_deadline) {
2250                return Err(StreamFailure::transient(StreamHandlerError::StreamFailed(
2251                    diagnostics.total_timeout(),
2252                )));
2253            }
2254            if cancel.is_cancelled() {
2255                return Err(StreamFailure {
2256                    error: StreamHandlerError::Cancelled,
2257                    retryable: false,
2258                });
2259            }
2260
2261            let event_deadline = self.event_deadline(diagnostics.events_processed);
2262            let event_result = tokio::select! {
2263                event = stream.next() => EventPoll::Next(event),
2264                () = cancel.notified() => return Err(StreamFailure {
2265                    error: StreamHandlerError::Cancelled,
2266                    retryable: false,
2267                }),
2268                () = deadline_future(event_deadline) => EventPoll::TimedOut,
2269                () = deadline_future(total_deadline) => {
2270                    return Err(StreamFailure::transient(
2271                        StreamHandlerError::StreamFailed(diagnostics.total_timeout()),
2272                    ));
2273                }
2274            };
2275            match event_result {
2276                EventPoll::TimedOut => {
2277                    *consecutive_timeouts = consecutive_timeouts.saturating_add(1);
2278                    let max_consecutive = if diagnostics.events_processed == 0 {
2279                        self.timeout_config.max_consecutive_timeouts.min(2) as usize
2280                    } else {
2281                        self.timeout_config.max_consecutive_timeouts as usize
2282                    };
2283                    if *consecutive_timeouts >= max_consecutive {
2284                        return Err(StreamFailure::transient(StreamHandlerError::StreamFailed(
2285                            diagnostics.event_timeout(
2286                                u32::try_from(*consecutive_timeouts).unwrap_or(u32::MAX),
2287                            ),
2288                        )));
2289                    }
2290                }
2291                EventPoll::Next(Some(Ok(event))) => return Ok(Some(event)),
2292                EventPoll::Next(Some(Err(api_error))) => {
2293                    return Err(diagnostics.api_error_failure(&api_error));
2294                }
2295                EventPoll::Next(None) => return Ok(None),
2296            }
2297        }
2298    }
2299
2300    /// The deadline for the next stream event, or `None` if disabled.
2301    ///
2302    /// Computes the instant at which the per-event timeout fires for the
2303    /// current poll: [`initial_event_timeout`](StreamTimeoutConfig::initial_event_timeout)
2304    /// before any event has arrived (the model may need time to begin
2305    /// generating), then [`per_event_timeout`](StreamTimeoutConfig::per_event_timeout)
2306    /// once events are flowing. A disabled timeout ([`Duration::MAX`])
2307    /// overflows `Instant::now() + timeout`, so `checked_add` returns `None`
2308    /// and the caller arms a never-firing `select!` branch. `events_processed`
2309    /// is the same counter [`next_event`](Self::next_event) maintains, so the
2310    /// deadline always matches the timeout phase the stream is in.
2311    fn event_deadline(&self, events_processed: u64) -> Option<Instant> {
2312        let base_timeout = if events_processed == 0 {
2313            self.timeout_config.initial_event_timeout
2314        } else {
2315            self.timeout_config.per_event_timeout
2316        };
2317        Instant::now().checked_add(base_timeout)
2318    }
2319
2320    /// Whether the total-stream deadline has already passed.
2321    ///
2322    /// Polled between events at the top of [`next_event`](Self::next_event)'s
2323    /// loop, before the per-event `select!` commits to another wait. This
2324    /// catches a deadline that elapsed while the loop was processing the
2325    /// previous event (or building diagnostics) — the
2326    /// [`deadline_future`] `select!` arm only fires *during* a wait, so
2327    /// without this check a long event handler could overshoot the deadline
2328    /// by up to one event's processing time.
2329    ///
2330    /// `None` means no total-stream deadline is configured (the turn is
2331    /// bounded only by the per-event timeout) and the function returns
2332    /// `false` for every poll.
2333    fn deadline_exceeded(total_deadline: Option<Instant>) -> bool {
2334        match total_deadline {
2335            Some(deadline) => Instant::now() >= deadline,
2336            None => false,
2337        }
2338    }
2339
2340    /// Fall back to non-streaming message creation.
2341    ///
2342    /// Called when streaming fails (timeout, retries exhausted) and
2343    /// `fallback_to_non_streaming` is enabled. Uses
2344    /// [`ApiClient::create_message_with_options`] with the turn's
2345    /// [`RequestOptions`](crate::structured::RequestOptions) — a configured
2346    /// `response_format` or `tool_constraint` applies to the fallback exactly
2347    /// as it did to the streaming attempt — to get a complete typed response:
2348    /// the message, stop reason, and token usage are returned directly, with
2349    /// no JSON parsing at this layer.
2350    ///
2351    /// While streaming-budget time remains, the request is raced against the
2352    /// turn's `total_deadline` (the same one that bounded the streaming
2353    /// attempts), so a hanging non-streaming call cannot outlive the budget
2354    /// the stream already spent. When the deadline has already expired by
2355    /// the time the fallback starts — the terminal total-timeout paths,
2356    /// where the expiry itself is what triggered the fallback — racing
2357    /// against it would kill the request on its first poll and make the
2358    /// configured fallback unreachable; instead the fallback gets one fresh
2359    /// budget of [`initial_event_timeout`] to produce its answer. In both
2360    /// cases the deadline bounds *waiting*, not completion: a response that
2361    /// has resolved by the time the select is polled is accepted even when
2362    /// it lands at or past the deadline — the answer exists and its tokens
2363    /// are already spent, so discarding it would trade finished work for
2364    /// wall-clock bookkeeping. `None` means no deadline is configured and
2365    /// the call is bounded only by cancellation and any client-level limits.
2366    ///
2367    /// # Errors
2368    ///
2369    /// Returns [`StreamHandlerError::FallbackFailed`] if the fallback request
2370    /// also fails or its deadline expires before it completes, or
2371    /// [`StreamHandlerError::Cancelled`] if the cancel signal fires.
2372    ///
2373    /// [`initial_event_timeout`]: StreamTimeoutConfig::initial_event_timeout
2374    async fn fallback_non_streaming<C: ApiClient>(
2375        &self,
2376        client: &C,
2377        request: &crate::api::StreamRequest,
2378        options: &crate::structured::RequestOptions,
2379        cancel: &Arc<CancelSignal>,
2380        total_deadline: Option<Instant>,
2381        stream_outcome: Option<StreamOutcome>,
2382    ) -> Result<(Message, StreamStopReason, Option<Usage>), StreamHandlerError> {
2383        if cancel.is_cancelled() {
2384            return Err(StreamHandlerError::Cancelled);
2385        }
2386
2387        let fallback_deadline = match total_deadline {
2388            Some(deadline) if deadline > Instant::now() => total_deadline,
2389            Some(_) => Instant::now().checked_add(self.timeout_config.initial_event_timeout),
2390            None => None,
2391        };
2392        let result = tokio::select! {
2393            biased;
2394
2395            () = cancel.notified() => {
2396                return Err(StreamHandlerError::Cancelled);
2397            }
2398            res = client.create_message_with_options(request, options.clone()) => res,
2399            () = deadline_future(fallback_deadline) => {
2400                return Err(StreamHandlerError::FallbackFailed {
2401                    stream_outcome: stream_outcome.unwrap_or(StreamOutcome::InitFailed {
2402                        attempts: 0,
2403                        last_error: "unknown".to_string(),
2404                    }),
2405                    fallback_error: "fallback request exceeded its deadline".to_string(),
2406                });
2407            }
2408        };
2409
2410        match result {
2411            Ok(response) => Ok((response.message, response.stop_reason, response.usage)),
2412            Err(e) => Err(StreamHandlerError::FallbackFailed {
2413                stream_outcome: stream_outcome.unwrap_or(StreamOutcome::InitFailed {
2414                    attempts: 0,
2415                    last_error: "unknown".to_string(),
2416                }),
2417                fallback_error: e.to_string(),
2418            }),
2419        }
2420    }
2421}
2422
2423/// Event yielded by [`StreamHandler::stream_turn`].
2424///
2425/// The engine drives the handler's turn as a stream of these events: real
2426/// stream events flow through to observers and the engine's accumulator;
2427/// retry boundaries and the non-streaming fallback are surfaced as
2428/// first-class signals so the engine can react (reset state, swap in the
2429/// fallback message).
2430///
2431/// See [`StreamHandler::stream_turn`] for the contract.
2432#[derive(Debug, Clone)]
2433#[non_exhaustive]
2434pub enum HandlerEvent {
2435    /// A raw event from the provider's stream (text delta, thinking delta,
2436    /// tool call, etc.).
2437    ///
2438    /// The engine forwards these to observers (`on_text_delta`,
2439    /// `on_thinking_delta`, `text_streamer`) exactly like the inline path,
2440    /// then feeds them to its own [`StreamAccumulator`].
2441    Stream(StreamEvent),
2442
2443    /// The handler is starting a new attempt after a retry decision.
2444    ///
2445    /// Fired before the first `Stream` event of attempts 2, 3, … (never on
2446    /// the first attempt). The engine must reset any per-attempt state —
2447    /// including its [`StreamAccumulator`] — so events from the failed
2448    /// attempt are discarded rather than concatenated with the retry's
2449    /// events. Observers that concatenate deltas per turn must reset too.
2450    AttemptReset,
2451
2452    /// Streaming retries are exhausted and the non-streaming fallback
2453    /// succeeded.
2454    ///
2455    /// Carries the final message, stop reason, and token usage from the
2456    /// non-streaming
2457    /// [`create_message_with_options`](crate::api::ApiClient::create_message_with_options)
2458    /// typed response, bounded by the turn's total deadline. The engine
2459    /// should stop accumulating and use these directly —
2460    /// the streaming accumulator's partial state from failed attempts is
2461    /// irrelevant on this path.
2462    ///
2463    /// Always the last event before the stream ends (when the fallback path
2464    /// is taken).
2465    Fallback {
2466        /// The fallback assistant message produced by the non-streaming
2467        /// request.
2468        ///
2469        /// Built from the typed
2470        /// [`NonStreamingResponse`](crate::api::NonStreamingResponse) returned
2471        /// by [`create_message`](crate::api::ApiClient::create_message). The
2472        /// engine should treat this as the authoritative turn output — the
2473        /// streaming accumulator's partial state from failed attempts is
2474        /// discarded on this path.
2475        message: Message,
2476
2477        /// Stop reason mapped from the provider's native finish/stop field.
2478        ///
2479        /// Defaults to [`EndTurn`](StreamStopReason::EndTurn) when the
2480        /// field is absent or holds an unrecognized value, so the engine
2481        /// always has a concrete reason to act on. Drives the same
2482        /// downstream behaviour as a streaming `MessageStop`.
2483        stop_reason: StreamStopReason,
2484
2485        /// Token usage reported by the provider for the fallback request.
2486        ///
2487        /// `None` when the provider omits usage from its non-streaming
2488        /// response. The engine threads this into the turn's usage totals
2489        /// exactly like the `MessageDelta` usage on the streaming path.
2490        usage: Option<Usage>,
2491    },
2492}
2493
2494#[cfg(test)]
2495mod tests {
2496    use super::*;
2497
2498    /// Test-only result shape matching the old `StreamTurnResult`, used by
2499    /// [`StreamHandler::drive_turn`] to keep existing tests' assertions working.
2500    #[derive(Debug)]
2501    #[allow(dead_code)]
2502    struct DriveResult {
2503        message: Message,
2504        usage: Option<Usage>,
2505        stop_reason: StreamStopReason,
2506        from_fallback: bool,
2507    }
2508
2509    impl StreamHandler {
2510        /// Drive `stream_turn` to completion and return the assembled
2511        /// `(Message, Option<Usage>, StreamStopReason, from_fallback)` tuple —
2512        /// the same shape `stream_turn` used to return directly. Mirrors how the
2513        /// engine consumes the stream: events accumulate, `AttemptReset`
2514        /// discards partial state, `Fallback` short-circuits with the fallback
2515        /// message.
2516        async fn drive_turn<C: ApiClient>(
2517            &self,
2518            client: &C,
2519            request: &crate::api::StreamRequest,
2520            cancel: &Arc<CancelSignal>,
2521        ) -> Result<DriveResult, StreamHandlerError> {
2522            let mut stream = self.stream_turn(
2523                client,
2524                request,
2525                crate::structured::RequestOptions::default(),
2526                cancel,
2527            );
2528            let mut accumulator = StreamAccumulator::new();
2529            let mut stop_reason = StreamStopReason::EndTurn;
2530            let mut from_fallback = false;
2531            while let Some(item) = stream.next().await {
2532                match item? {
2533                    HandlerEvent::Stream(ev) => {
2534                        if let StreamEvent::MessageDelta(delta) = &ev
2535                            && let Some(ref reason_str) = delta.delta.stop_reason
2536                        {
2537                            stop_reason =
2538                                StreamStopReason::from_api_str(reason_str).unwrap_or(stop_reason);
2539                        }
2540                        accumulator.process(&ev).map_err(|e| {
2541                            StreamHandlerError::StreamFailed(StreamOutcome::InitFailed {
2542                                attempts: 1,
2543                                last_error: e.to_string(),
2544                            })
2545                        })?;
2546                    }
2547                    HandlerEvent::AttemptReset => {
2548                        accumulator = StreamAccumulator::new();
2549                        stop_reason = StreamStopReason::EndTurn;
2550                    }
2551                    HandlerEvent::Fallback {
2552                        message,
2553                        stop_reason: fallback_stop_reason,
2554                        usage: fallback_usage,
2555                    } => {
2556                        from_fallback = true;
2557                        return Ok(DriveResult {
2558                            message,
2559                            usage: fallback_usage,
2560                            stop_reason: fallback_stop_reason,
2561                            from_fallback,
2562                        });
2563                    }
2564                }
2565            }
2566            let usage = accumulator.usage().copied();
2567            Ok(DriveResult {
2568                message: accumulator.build(),
2569                usage,
2570                stop_reason,
2571                from_fallback,
2572            })
2573        }
2574    }
2575
2576    #[test]
2577    fn timeout_config_default_values() {
2578        let config = StreamTimeoutConfig::default();
2579        assert_eq!(config.initial_event_timeout, Duration::from_mins(2));
2580        assert_eq!(config.per_event_timeout, Duration::from_mins(3));
2581        assert_eq!(config.total_stream_timeout, Duration::from_mins(5));
2582        assert_eq!(config.max_consecutive_timeouts, 10);
2583        assert!(config.fallback_to_non_streaming);
2584    }
2585
2586    #[test]
2587    fn passthrough_sets_no_resilience_config() {
2588        let h = StreamHandler::passthrough();
2589        assert_eq!(h.timeout_config().initial_event_timeout, Duration::MAX);
2590        assert_eq!(h.timeout_config().per_event_timeout, Duration::MAX);
2591        assert_eq!(h.timeout_config().total_stream_timeout, Duration::MAX);
2592        assert!(!h.timeout_config().fallback_to_non_streaming);
2593        assert_eq!(h.retry_config().max_retries, 0);
2594        assert_eq!(h.rate_limit_config().max_retries, 0);
2595        assert_eq!(h.rate_limit_config().fallback_after_retries, 0);
2596    }
2597
2598    #[test]
2599    fn passthrough_default_returns_shared_static() {
2600        let a = StreamHandler::passthrough_default();
2601        let b = StreamHandler::passthrough_default();
2602        assert!(
2603            std::ptr::eq(a, b),
2604            "passthrough_default must return the same static"
2605        );
2606    }
2607
2608    #[test]
2609    fn timeout_config_custom_values() {
2610        let config = StreamTimeoutConfig {
2611            initial_event_timeout: Duration::from_secs(30),
2612            per_event_timeout: Duration::from_mins(1),
2613            total_stream_timeout: Duration::from_mins(5),
2614            max_consecutive_timeouts: 5,
2615            fallback_to_non_streaming: false,
2616        };
2617        assert_eq!(config.initial_event_timeout, Duration::from_secs(30));
2618        assert!(!config.fallback_to_non_streaming);
2619    }
2620
2621    #[test]
2622    fn retry_config_default_values() {
2623        let config = StreamRetryConfig::default();
2624        assert_eq!(config.max_retries, 3);
2625        assert_eq!(config.base_delay_ms, 100);
2626        assert_eq!(config.max_delay_ms, 10_000);
2627        assert!((config.jitter_factor - 0.1).abs() < f64::EPSILON);
2628    }
2629
2630    #[test]
2631    fn retry_config_base_delay_exponential() {
2632        let config = StreamRetryConfig::default();
2633        assert_eq!(config.base_delay(0), Duration::from_millis(100));
2634        assert_eq!(config.base_delay(1), Duration::from_millis(200));
2635        assert_eq!(config.base_delay(2), Duration::from_millis(400));
2636        assert_eq!(config.base_delay(3), Duration::from_millis(800));
2637    }
2638
2639    #[test]
2640    fn retry_config_base_delay_capped_at_max() {
2641        let config = StreamRetryConfig {
2642            base_delay_ms: 1000,
2643            max_delay_ms: 5000,
2644            ..Default::default()
2645        };
2646        assert_eq!(config.base_delay(3), Duration::from_secs(5));
2647    }
2648
2649    #[test]
2650    fn jittered_base_delay_zero_jitter_equals_raw() {
2651        let config = StreamRetryConfig {
2652            jitter_factor: 0.0,
2653            ..Default::default()
2654        };
2655        for attempt in 0..5 {
2656            assert_eq!(
2657                config.jittered_base_delay(attempt),
2658                config.base_delay(attempt),
2659                "zero jitter must reproduce the raw backoff exactly"
2660            );
2661        }
2662    }
2663
2664    #[test]
2665    fn jittered_base_delay_stays_within_jitter_band() {
2666        let config = StreamRetryConfig {
2667            base_delay_ms: 100,
2668            max_delay_ms: 100_000,
2669            jitter_factor: 0.2,
2670            ..Default::default()
2671        };
2672        for attempt in 0..64 {
2673            let base = config.base_delay(attempt);
2674            let delay = config.jittered_base_delay(attempt);
2675            let lo = base.mul_f64(0.8);
2676            let hi = base.mul_f64(1.2);
2677            assert!(
2678                delay >= lo && delay <= hi,
2679                "attempt {attempt}: jittered delay {delay:?} outside [{lo:?}, {hi:?}]"
2680            );
2681        }
2682    }
2683
2684    #[test]
2685    fn jittered_base_delay_concurrent_calls_produce_different_delays() {
2686        let config = StreamRetryConfig {
2687            base_delay_ms: 100,
2688            max_delay_ms: 100_000,
2689            jitter_factor: 0.5,
2690            ..Default::default()
2691        };
2692        let attempt = 1;
2693        let mut delays: Vec<_> = (0..10)
2694            .map(|_| config.jittered_base_delay(attempt))
2695            .collect();
2696        delays.sort();
2697        delays.dedup();
2698        assert!(
2699            delays.len() > 1,
2700            "concurrent calls with the same attempt must produce varied delays"
2701        );
2702    }
2703
2704    #[test]
2705    fn jittered_base_delay_max_jitter_stays_non_negative() {
2706        let config = StreamRetryConfig {
2707            base_delay_ms: 100,
2708            max_delay_ms: 100_000,
2709            jitter_factor: 1.0,
2710            ..Default::default()
2711        };
2712        for attempt in 0..256 {
2713            let delay = config.jittered_base_delay(attempt);
2714            let hi = config.base_delay(attempt).mul_f64(2.0);
2715            assert!(
2716                delay <= hi,
2717                "attempt {attempt}: delay {delay:?} exceeds 2x base under max jitter"
2718            );
2719        }
2720    }
2721
2722    #[test]
2723    fn outcome_completed_display() {
2724        let outcome = StreamOutcome::Completed {
2725            events_processed: 42,
2726            duration: Duration::from_secs(5),
2727        };
2728        let s = outcome.to_string();
2729        assert!(s.contains("42 events"));
2730        assert!(s.contains("5.0s"));
2731    }
2732
2733    #[test]
2734    fn outcome_total_timeout_display() {
2735        let outcome = StreamOutcome::TotalTimeout {
2736            has_partial_data: true,
2737            events_processed: 10,
2738            duration: Duration::from_mins(15),
2739        };
2740        let s = outcome.to_string();
2741        assert!(s.contains("partial data"));
2742        assert!(s.contains("900.0s"));
2743    }
2744
2745    #[test]
2746    fn outcome_event_timeout_display() {
2747        let outcome = StreamOutcome::EventTimeout {
2748            has_partial_data: false,
2749            consecutive_timeouts: 10,
2750        };
2751        let s = outcome.to_string();
2752        assert!(s.contains("10 consecutive"));
2753        assert!(!s.contains("partial data"));
2754    }
2755
2756    #[test]
2757    fn outcome_init_failed_display() {
2758        let outcome = StreamOutcome::InitFailed {
2759            last_error: "connection refused".to_string(),
2760            attempts: 3,
2761        };
2762        let s = outcome.to_string();
2763        assert!(s.contains("3 attempts"));
2764        assert!(s.contains("connection refused"));
2765        assert!(
2766            !s.contains("init failed"),
2767            "the historical variant name must not leak into the rendered \
2768             message — a mid-stream truncation is not an init failure: {s}"
2769        );
2770    }
2771
2772    #[test]
2773    fn outcome_fallback_display() {
2774        let outcome = StreamOutcome::FallbackToNonStreaming;
2775        let s = outcome.to_string();
2776        assert!(s.contains("non-streaming"));
2777    }
2778
2779    #[test]
2780    fn outcome_cancelled_display() {
2781        let outcome = StreamOutcome::Cancelled;
2782        assert_eq!(outcome.to_string(), "cancelled");
2783    }
2784
2785    #[test]
2786    fn error_init_failed_display() {
2787        let outcome = StreamOutcome::InitFailed {
2788            last_error: "timeout".to_string(),
2789            attempts: 3,
2790        };
2791        let err = StreamHandlerError::InitFailed(outcome);
2792        let s = err.to_string();
2793        assert!(
2794            s.contains("stream failed before completing"),
2795            "the historical variant name must not leak into the message: {s}"
2796        );
2797    }
2798
2799    #[test]
2800    fn error_stream_failed_display() {
2801        let outcome = StreamOutcome::EventTimeout {
2802            has_partial_data: true,
2803            consecutive_timeouts: 5,
2804        };
2805        let err = StreamHandlerError::StreamFailed(outcome);
2806        let s = err.to_string();
2807        assert!(s.contains("stream failed"));
2808    }
2809
2810    #[test]
2811    fn error_fallback_failed_display() {
2812        let stream_outcome = StreamOutcome::TotalTimeout {
2813            has_partial_data: false,
2814            events_processed: 0,
2815            duration: Duration::from_mins(15),
2816        };
2817        let err = StreamHandlerError::FallbackFailed {
2818            stream_outcome,
2819            fallback_error: "api error 429".to_string(),
2820        };
2821        let s = err.to_string();
2822        assert!(s.contains("fallback also failed"));
2823        assert!(s.contains("429"));
2824    }
2825
2826    #[test]
2827    fn error_cancelled_display() {
2828        let err = StreamHandlerError::Cancelled;
2829        assert_eq!(err.to_string(), "cancelled");
2830    }
2831
2832    #[test]
2833    fn error_rate_limit_escalation_display() {
2834        let err = StreamHandlerError::RateLimitEscalation {
2835            attempts: 4,
2836            retry_after: Some(Duration::from_secs(5)),
2837        };
2838        let s = err.to_string();
2839        assert!(s.contains("rate-limit escalation"), "got: {s}");
2840        assert!(s.contains("4 retries"), "got: {s}");
2841        assert!(
2842            s.contains("5s"),
2843            "should render the retry-after duration, got: {s}"
2844        );
2845    }
2846
2847    #[test]
2848    fn handler_new_defaults() {
2849        let handler = StreamHandler::new();
2850        assert_eq!(
2851            handler.timeout_config().initial_event_timeout,
2852            Duration::from_mins(2),
2853        );
2854        assert_eq!(handler.retry_config().max_retries, 3);
2855    }
2856
2857    #[test]
2858    fn handler_with_timeout_and_retry_config() {
2859        let handler = StreamHandler::new()
2860            .with_timeout_config(StreamTimeoutConfig {
2861                initial_event_timeout: Duration::from_mins(1),
2862                ..Default::default()
2863            })
2864            .with_retry_config(StreamRetryConfig {
2865                max_retries: 5,
2866                ..Default::default()
2867            });
2868        assert_eq!(
2869            handler.timeout_config().initial_event_timeout,
2870            Duration::from_mins(1),
2871        );
2872        assert_eq!(handler.retry_config().max_retries, 5);
2873    }
2874
2875    #[test]
2876    fn handler_default_trait() {
2877        let handler = StreamHandler::default();
2878        assert_eq!(
2879            handler.timeout_config().initial_event_timeout,
2880            Duration::from_mins(2),
2881        );
2882    }
2883
2884    #[test]
2885    fn handler_debug_format() {
2886        let handler = StreamHandler::new();
2887        let debug = format!("{handler:?}");
2888        assert!(debug.contains("StreamHandler"));
2889        assert!(debug.contains("timeout_config"));
2890    }
2891
2892    #[test]
2893    fn timeout_config_validate_rejects_infinite_total_timeout() {
2894        let config = StreamTimeoutConfig {
2895            total_stream_timeout: Duration::MAX,
2896            ..Default::default()
2897        };
2898        let err = config
2899            .validate()
2900            .expect_err("Duration::MAX must be rejected");
2901        assert!(
2902            err.contains("finite"),
2903            "the error must name the silent-disable hazard: {err}"
2904        );
2905    }
2906
2907    #[test]
2908    fn timeout_config_validate_default_ok() {
2909        assert!(StreamTimeoutConfig::default().validate().is_ok());
2910    }
2911
2912    #[test]
2913    fn timeout_config_validate_zero_initial() {
2914        let config = StreamTimeoutConfig {
2915            initial_event_timeout: Duration::ZERO,
2916            ..Default::default()
2917        };
2918        let err = config.validate().unwrap_err();
2919        assert!(err.contains("initial_event_timeout"));
2920    }
2921
2922    #[test]
2923    fn timeout_config_validate_zero_per_event() {
2924        let config = StreamTimeoutConfig {
2925            per_event_timeout: Duration::ZERO,
2926            ..Default::default()
2927        };
2928        let err = config.validate().unwrap_err();
2929        assert!(err.contains("per_event_timeout"));
2930    }
2931
2932    #[test]
2933    fn timeout_config_validate_zero_total() {
2934        let config = StreamTimeoutConfig {
2935            total_stream_timeout: Duration::ZERO,
2936            ..Default::default()
2937        };
2938        let err = config.validate().unwrap_err();
2939        assert!(err.contains("total_stream_timeout"));
2940    }
2941
2942    #[test]
2943    fn timeout_config_validate_total_less_than_initial() {
2944        let config = StreamTimeoutConfig {
2945            initial_event_timeout: Duration::from_mins(2),
2946            total_stream_timeout: Duration::from_mins(1),
2947            ..Default::default()
2948        };
2949        let err = config.validate().unwrap_err();
2950        assert!(err.contains("total_stream_timeout"));
2951        assert!(err.contains("initial_event_timeout"));
2952    }
2953
2954    #[test]
2955    fn retry_config_validate_default_ok() {
2956        assert!(StreamRetryConfig::default().validate().is_ok());
2957    }
2958
2959    #[test]
2960    fn retry_config_validate_zero_base_delay() {
2961        let config = StreamRetryConfig {
2962            base_delay_ms: 0,
2963            ..Default::default()
2964        };
2965        let err = config.validate().unwrap_err();
2966        assert!(err.contains("base_delay_ms"));
2967    }
2968
2969    #[test]
2970    fn retry_config_validate_zero_max_delay() {
2971        let config = StreamRetryConfig {
2972            max_delay_ms: 0,
2973            ..Default::default()
2974        };
2975        let err = config.validate().unwrap_err();
2976        assert!(err.contains("max_delay_ms"));
2977    }
2978
2979    #[test]
2980    fn retry_config_validate_max_less_than_base() {
2981        let config = StreamRetryConfig {
2982            base_delay_ms: 1000,
2983            max_delay_ms: 500,
2984            ..Default::default()
2985        };
2986        let err = config.validate().unwrap_err();
2987        assert!(err.contains("max_delay_ms"));
2988        assert!(err.contains("base_delay_ms"));
2989    }
2990
2991    #[test]
2992    fn retry_config_validate_jitter_nan() {
2993        let config = StreamRetryConfig {
2994            jitter_factor: f64::NAN,
2995            ..Default::default()
2996        };
2997        let err = config.validate().unwrap_err();
2998        assert!(err.contains("finite"));
2999    }
3000
3001    #[test]
3002    fn retry_config_validate_jitter_infinity() {
3003        let config = StreamRetryConfig {
3004            jitter_factor: f64::INFINITY,
3005            ..Default::default()
3006        };
3007        let err = config.validate().unwrap_err();
3008        assert!(err.contains("finite"));
3009    }
3010
3011    #[test]
3012    fn retry_config_validate_jitter_above_one() {
3013        let config = StreamRetryConfig {
3014            jitter_factor: 1.5,
3015            ..Default::default()
3016        };
3017        let err = config.validate().unwrap_err();
3018        assert!(err.contains("0.0..=1.0"));
3019    }
3020
3021    #[test]
3022    fn retry_config_validate_jitter_negative() {
3023        let config = StreamRetryConfig {
3024            jitter_factor: -0.1,
3025            ..Default::default()
3026        };
3027        let err = config.validate().unwrap_err();
3028        assert!(err.contains("0.0..=1.0"));
3029    }
3030
3031    #[test]
3032    fn retry_config_validate_jitter_boundaries() {
3033        // 0.0 and 1.0 are valid boundaries.
3034        let config = StreamRetryConfig {
3035            jitter_factor: 0.0,
3036            ..Default::default()
3037        };
3038        assert!(config.validate().is_ok());
3039
3040        let config = StreamRetryConfig {
3041            jitter_factor: 1.0,
3042            ..Default::default()
3043        };
3044        assert!(config.validate().is_ok());
3045    }
3046
3047    use crate::api::error::ApiError;
3048    use crate::stream::{
3049        DeltaPart, IndexedDelta, MessageDelta, MessageDeltaPayload, MessageMetadata, MessageStart,
3050        PartStart, StreamEvent, Usage,
3051    };
3052
3053    fn happy_stream_events() -> Vec<Result<StreamEvent, ApiError>> {
3054        vec![
3055            Ok(StreamEvent::MessageStart(MessageStart {
3056                message: MessageMetadata {
3057                    id: "msg_test".to_string(),
3058                    role: "assistant".to_string(),
3059                    model: "test-model".to_string(),
3060                },
3061            })),
3062            Ok(StreamEvent::PartStart(PartStart {
3063                index: 0,
3064                part: Some(crate::stream::MessagePart::text("")),
3065            })),
3066            Ok(StreamEvent::IndexedDelta(IndexedDelta {
3067                index: 0,
3068                delta: DeltaPart::Text {
3069                    text: "hi".to_string(),
3070                },
3071            })),
3072            Ok(StreamEvent::PartStop { index: None }),
3073            Ok(StreamEvent::MessageDelta(MessageDelta {
3074                delta: MessageDeltaPayload {
3075                    stop_reason: Some("end_turn".to_string()),
3076                },
3077                usage: None,
3078            })),
3079            Ok(StreamEvent::MessageStop),
3080        ]
3081    }
3082
3083    struct HandlerMock {
3084        create_error: Option<String>,
3085        create_response: Option<Message>,
3086    }
3087
3088    impl HandlerMock {
3089        fn new() -> Self {
3090            Self {
3091                create_error: None,
3092                create_response: None,
3093            }
3094        }
3095
3096        fn with_text_response(mut self, text: &str) -> Self {
3097            self.create_response = Some(Message::assistant(text));
3098            self
3099        }
3100
3101        fn with_create_error(mut self, msg: &str) -> Self {
3102            self.create_error = Some(msg.to_string());
3103            self
3104        }
3105    }
3106
3107    impl ApiClient for HandlerMock {
3108        fn model(&self) -> String {
3109            "test-model".to_string()
3110        }
3111
3112        fn stream_messages(
3113            &self,
3114            _request: &crate::api::StreamRequest,
3115        ) -> std::pin::Pin<
3116            Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3117        > {
3118            // Default: return a happy-path stream.
3119            Box::pin(futures::stream::iter(happy_stream_events()))
3120        }
3121
3122        fn create_message(
3123            &self,
3124            _request: &crate::api::StreamRequest,
3125        ) -> std::pin::Pin<
3126            Box<
3127                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3128                    + Send
3129                    + '_,
3130            >,
3131        > {
3132            if let Some(ref err) = self.create_error {
3133                let err = err.clone();
3134                return Box::pin(async move { Err(ApiError::api(&err)) });
3135            }
3136            let message = self
3137                .create_response
3138                .clone()
3139                .unwrap_or_else(|| Message::assistant("default"));
3140            Box::pin(async move {
3141                Ok(crate::api::NonStreamingResponse {
3142                    message,
3143                    stop_reason: crate::stream::StreamStopReason::EndTurn,
3144                    usage: Some(crate::stream::Usage::default()),
3145                })
3146            })
3147        }
3148    }
3149
3150    #[tokio::test]
3151    async fn fallback_non_streaming_success() {
3152        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3153            fallback_to_non_streaming: true,
3154            ..Default::default()
3155        });
3156        let client = HandlerMock::new().with_text_response("fallback works");
3157        let cancel = Arc::new(CancelSignal::new());
3158
3159        let (message, stop_reason, usage) = handler
3160            .fallback_non_streaming(
3161                &client,
3162                &crate::api::StreamRequest::new(vec![]),
3163                &crate::structured::RequestOptions::default(),
3164                &cancel,
3165                None,
3166                Some(StreamOutcome::InitFailed {
3167                    last_error: "stream failed".to_string(),
3168                    attempts: 3,
3169                }),
3170            )
3171            .await
3172            .expect("fallback should succeed");
3173
3174        // The fallback returns a Message built from the first text part of the
3175        // non-streaming JSON response, plus the stop_reason from the JSON.
3176        let text: String = message
3177            .parts
3178            .iter()
3179            .filter_map(|p| match p {
3180                crate::stream::MessagePart::Text { text } => Some(text.clone()),
3181                _ => None,
3182            })
3183            .collect();
3184        assert!(text.contains("fallback works"), "got: {text:?}");
3185        // HandlerMock::with_text_response sets stop_reason: "end_turn".
3186        assert_eq!(stop_reason, StreamStopReason::EndTurn);
3187        // HandlerMock returns Usage::default() (zero tokens).
3188        assert_eq!(usage, Some(Usage::default()));
3189    }
3190
3191    #[tokio::test]
3192    async fn fallback_non_streaming_cancelled_before_start() {
3193        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3194            fallback_to_non_streaming: true,
3195            ..Default::default()
3196        });
3197        let client = HandlerMock::new().with_text_response("fallback works");
3198        let cancel = Arc::new(CancelSignal::new());
3199        cancel.cancel();
3200
3201        let err = handler
3202            .fallback_non_streaming(
3203                &client,
3204                &crate::api::StreamRequest::new(vec![]),
3205                &crate::structured::RequestOptions::default(),
3206                &cancel,
3207                None,
3208                None,
3209            )
3210            .await
3211            .expect_err("should fail on cancellation");
3212
3213        assert!(
3214            matches!(err, StreamHandlerError::Cancelled),
3215            "expected Cancelled, got: {err}"
3216        );
3217    }
3218
3219    /// Mock recording every `create_message_with_options` invocation, so the
3220    /// fallback's options forwarding is observable.
3221    struct OptionsRecordingMock {
3222        seen: std::sync::Mutex<Vec<crate::structured::RequestOptions>>,
3223    }
3224
3225    impl ApiClient for OptionsRecordingMock {
3226        fn model(&self) -> String {
3227            "test-model".to_string()
3228        }
3229
3230        fn stream_messages(
3231            &self,
3232            _request: &crate::api::StreamRequest,
3233        ) -> std::pin::Pin<
3234            Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3235        > {
3236            Box::pin(futures::stream::empty())
3237        }
3238
3239        fn create_message(
3240            &self,
3241            _request: &crate::api::StreamRequest,
3242        ) -> std::pin::Pin<
3243            Box<
3244                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3245                    + Send
3246                    + '_,
3247            >,
3248        > {
3249            Box::pin(async {
3250                Ok(crate::api::NonStreamingResponse {
3251                    message: Message::assistant("unused"),
3252                    stop_reason: crate::stream::StreamStopReason::EndTurn,
3253                    usage: Some(crate::stream::Usage::default()),
3254                })
3255            })
3256        }
3257
3258        fn create_message_with_options(
3259            &self,
3260            _request: &crate::api::StreamRequest,
3261            options: crate::structured::RequestOptions,
3262        ) -> std::pin::Pin<
3263            Box<
3264                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3265                    + Send
3266                    + '_,
3267            >,
3268        > {
3269            self.seen.lock().unwrap().push(options);
3270            Box::pin(async {
3271                Ok(crate::api::NonStreamingResponse {
3272                    message: Message::assistant("fallback works"),
3273                    stop_reason: crate::stream::StreamStopReason::EndTurn,
3274                    usage: Some(crate::stream::Usage::default()),
3275                })
3276            })
3277        }
3278    }
3279
3280    #[tokio::test]
3281    async fn fallback_non_streaming_forwards_request_options() {
3282        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3283            fallback_to_non_streaming: true,
3284            ..Default::default()
3285        });
3286        let client = OptionsRecordingMock {
3287            seen: std::sync::Mutex::new(Vec::new()),
3288        };
3289        let cancel = Arc::new(CancelSignal::new());
3290
3291        let mut options = crate::structured::RequestOptions::default();
3292        options.response_format = Some(crate::structured::ResponseFormat::new(
3293            "probe",
3294            serde_json::json!({"type": "object"}),
3295        ));
3296
3297        let (message, _stop, _usage) = handler
3298            .fallback_non_streaming(
3299                &client,
3300                &crate::api::StreamRequest::new(vec![]),
3301                &options,
3302                &cancel,
3303                None,
3304                Some(StreamOutcome::InitFailed {
3305                    last_error: "stream failed".to_string(),
3306                    attempts: 1,
3307                }),
3308            )
3309            .await
3310            .expect("fallback should succeed");
3311
3312        assert!(
3313            message.text_content().contains("fallback works"),
3314            "the options-aware response is the one used"
3315        );
3316        let seen = client.seen.lock().unwrap();
3317        assert_eq!(seen.len(), 1, "exactly one options-aware call");
3318        assert!(
3319            seen[0]
3320                .response_format
3321                .as_ref()
3322                .is_some_and(|format| format.name == "probe"),
3323            "the fallback must receive the turn's RequestOptions verbatim"
3324        );
3325    }
3326
3327    /// Mock whose non-streaming call never resolves — the deadline arm must
3328    /// win.
3329    struct HangingFallbackMock;
3330
3331    impl ApiClient for HangingFallbackMock {
3332        fn model(&self) -> String {
3333            "test-model".to_string()
3334        }
3335
3336        fn stream_messages(
3337            &self,
3338            _request: &crate::api::StreamRequest,
3339        ) -> std::pin::Pin<
3340            Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3341        > {
3342            Box::pin(futures::stream::empty())
3343        }
3344
3345        fn create_message(
3346            &self,
3347            _request: &crate::api::StreamRequest,
3348        ) -> std::pin::Pin<
3349            Box<
3350                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3351                    + Send
3352                    + '_,
3353            >,
3354        > {
3355            Box::pin(std::future::pending())
3356        }
3357    }
3358
3359    #[tokio::test]
3360    async fn fallback_non_streaming_honors_the_total_deadline() {
3361        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3362            fallback_to_non_streaming: true,
3363            ..Default::default()
3364        });
3365        let cancel = Arc::new(CancelSignal::new());
3366        let deadline = Instant::now() + Duration::from_millis(10);
3367
3368        let err = handler
3369            .fallback_non_streaming(
3370                &HangingFallbackMock,
3371                &crate::api::StreamRequest::new(vec![]),
3372                &crate::structured::RequestOptions::default(),
3373                &cancel,
3374                Some(deadline),
3375                None,
3376            )
3377            .await
3378            .expect_err("a hanging fallback must be cut by the deadline");
3379
3380        match err {
3381            StreamHandlerError::FallbackFailed { fallback_error, .. } => {
3382                assert!(
3383                    fallback_error.contains("deadline"),
3384                    "the deadline arm must be the failure cause: {fallback_error}"
3385                );
3386            }
3387            other => panic!("expected FallbackFailed, got: {other}"),
3388        }
3389    }
3390
3391    #[tokio::test]
3392    async fn completed_fallback_response_racing_the_deadline_is_accepted() {
3393        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3394            fallback_to_non_streaming: true,
3395            ..Default::default()
3396        });
3397        let client = HandlerMock::new().with_text_response("worth keeping");
3398        let cancel = Arc::new(CancelSignal::new());
3399        let deadline = Instant::now()
3400            .checked_sub(Duration::from_millis(1))
3401            .expect("a past instant");
3402
3403        let (message, _stop_reason, _usage) = handler
3404            .fallback_non_streaming(
3405                &client,
3406                &crate::api::StreamRequest::new(vec![]),
3407                &crate::structured::RequestOptions::default(),
3408                &cancel,
3409                Some(deadline),
3410                None,
3411            )
3412            .await
3413            .expect("a completed response outranks the expired deadline");
3414        assert!(
3415            message.text_content().contains("worth keeping"),
3416            "the completed response is returned, not discarded"
3417        );
3418    }
3419
3420    #[tokio::test]
3421    async fn fallback_non_streaming_error() {
3422        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
3423            fallback_to_non_streaming: true,
3424            ..Default::default()
3425        });
3426        let client = HandlerMock::new().with_create_error("service unavailable");
3427        let cancel = Arc::new(CancelSignal::new());
3428
3429        let err = handler
3430            .fallback_non_streaming(
3431                &client,
3432                &crate::api::StreamRequest::new(vec![]),
3433                &crate::structured::RequestOptions::default(),
3434                &cancel,
3435                None,
3436                Some(StreamOutcome::InitFailed {
3437                    last_error: "stream timeout".to_string(),
3438                    attempts: 2,
3439                }),
3440            )
3441            .await
3442            .expect_err("should fail when fallback also errors");
3443
3444        match err {
3445            StreamHandlerError::FallbackFailed {
3446                stream_outcome,
3447                fallback_error,
3448            } => {
3449                let stream_s = stream_outcome.to_string();
3450                assert!(
3451                    stream_s.contains("stream timeout"),
3452                    "unexpected: {stream_s}"
3453                );
3454                assert!(
3455                    fallback_error.contains("service unavailable"),
3456                    "unexpected: {fallback_error}"
3457                );
3458            }
3459            other => panic!("expected FallbackFailed, got: {other}"),
3460        }
3461    }
3462
3463    #[tokio::test]
3464    async fn stream_turn_yields_handler_event_stream_per_event() {
3465        // Each raw stream event must arrive as a HandlerEvent::Stream, in
3466        // arrival order, when driving the handler as a stream.
3467        let handler = StreamHandler::new();
3468        let client = HandlerMock::new().with_text_response("hello");
3469        let cancel = Arc::new(CancelSignal::new());
3470
3471        let req = crate::api::StreamRequest::new(vec![]);
3472        let mut stream = handler.stream_turn(
3473            &client,
3474            &req,
3475            crate::structured::RequestOptions::default(),
3476            &cancel,
3477        );
3478        let mut saw_stream_events = 0;
3479        let mut saw_attempt_reset = false;
3480        let mut saw_fallback = false;
3481        while let Some(item) = stream.next().await {
3482            match item.expect("stream item ok") {
3483                HandlerEvent::Stream(_) => saw_stream_events += 1,
3484                HandlerEvent::AttemptReset => saw_attempt_reset = true,
3485                HandlerEvent::Fallback { .. } => saw_fallback = true,
3486            }
3487        }
3488        assert!(saw_stream_events > 0, "should yield Stream events");
3489        assert!(!saw_attempt_reset, "happy path must not emit AttemptReset");
3490        assert!(!saw_fallback, "happy path must not emit Fallback");
3491    }
3492
3493    #[tokio::test]
3494    async fn empty_stream_fast_fails_after_lower_threshold() {
3495        struct NeverYieldingMock;
3496        impl ApiClient for NeverYieldingMock {
3497            fn model(&self) -> String {
3498                "stuck".to_string()
3499            }
3500            fn stream_messages(
3501                &self,
3502                _request: &crate::api::StreamRequest,
3503            ) -> std::pin::Pin<
3504                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3505            > {
3506                Box::pin(futures::stream::pending())
3507            }
3508            fn create_message(
3509                &self,
3510                _request: &crate::api::StreamRequest,
3511            ) -> std::pin::Pin<
3512                Box<
3513                    dyn std::future::Future<
3514                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
3515                        > + Send
3516                        + '_,
3517                >,
3518            > {
3519                Box::pin(async {
3520                    Ok(crate::api::NonStreamingResponse {
3521                        message: crate::message::Message::assistant(""),
3522                        stop_reason: crate::stream::StreamStopReason::EndTurn,
3523                        usage: Some(crate::stream::Usage::default()),
3524                    })
3525                })
3526            }
3527        }
3528
3529        let handler = StreamHandler::new()
3530            .with_timeout_config(StreamTimeoutConfig {
3531                initial_event_timeout: Duration::from_millis(10),
3532                per_event_timeout: Duration::from_millis(10),
3533                total_stream_timeout: Duration::from_secs(10),
3534                max_consecutive_timeouts: 10,
3535                fallback_to_non_streaming: false,
3536            })
3537            .with_retry_config(StreamRetryConfig {
3538                max_retries: 0,
3539                ..Default::default()
3540            });
3541        let client = NeverYieldingMock;
3542        let cancel = Arc::new(CancelSignal::new());
3543        let req = crate::api::StreamRequest::new(vec![]);
3544        let mut stream = handler.stream_turn(
3545            &client,
3546            &req,
3547            crate::structured::RequestOptions::default(),
3548            &cancel,
3549        );
3550        let start = Instant::now();
3551        let mut got = None;
3552        while let Some(item) = stream.next().await {
3553            if item.is_err() {
3554                got = Some(item);
3555                break;
3556            }
3557        }
3558        let elapsed = start.elapsed();
3559        match got.expect("stream must terminate with an error") {
3560            Err(StreamHandlerError::StreamFailed(StreamOutcome::EventTimeout { .. })) => {}
3561            other => panic!("expected EventTimeout on dead stream, got {other:?}"),
3562        }
3563        assert!(
3564            elapsed < Duration::from_millis(60),
3565            "empty-stream fast-fail (2×10ms) must beat the full threshold (10×10ms); \
3566             elapsed {elapsed:?}",
3567        );
3568    }
3569
3570    #[tokio::test]
3571    async fn fallback_preserves_tool_call_parts() {
3572        struct ToolFallbackMock;
3573        impl ApiClient for ToolFallbackMock {
3574            fn model(&self) -> String {
3575                "test".to_string()
3576            }
3577            fn stream_messages(
3578                &self,
3579                _request: &crate::api::StreamRequest,
3580            ) -> std::pin::Pin<
3581                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3582            > {
3583                Box::pin(futures::stream::once(async {
3584                    Err(ApiError::api("connection refused"))
3585                }))
3586            }
3587            fn create_message(
3588                &self,
3589                _request: &crate::api::StreamRequest,
3590            ) -> std::pin::Pin<
3591                Box<
3592                    dyn std::future::Future<
3593                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
3594                        > + Send
3595                        + '_,
3596                >,
3597            > {
3598                Box::pin(async {
3599                    Ok(crate::api::NonStreamingResponse {
3600                        message: crate::message::Message::new(
3601                            crate::message::Role::Assistant,
3602                            vec![
3603                                crate::message::MessagePart::text("Let me search"),
3604                                crate::message::MessagePart::tool_call(
3605                                    "tc_1",
3606                                    "search",
3607                                    serde_json::json!({"q": "hello"}),
3608                                ),
3609                            ],
3610                        ),
3611                        stop_reason: crate::stream::StreamStopReason::ToolCall,
3612                        usage: Some(crate::stream::Usage::default()),
3613                    })
3614                })
3615            }
3616        }
3617
3618        let handler = StreamHandler::new()
3619            .with_timeout_config(StreamTimeoutConfig {
3620                fallback_to_non_streaming: true,
3621                ..Default::default()
3622            })
3623            .with_retry_config(StreamRetryConfig {
3624                max_retries: 0,
3625                ..Default::default()
3626            });
3627        let cancel = Arc::new(CancelSignal::new());
3628        let req = crate::api::StreamRequest::new(vec![]);
3629        let mut stream = handler.stream_turn(
3630            &ToolFallbackMock,
3631            &req,
3632            crate::structured::RequestOptions::default(),
3633            &cancel,
3634        );
3635        let mut got_fallback = false;
3636        while let Some(item) = stream.next().await {
3637            if let Ok(HandlerEvent::Fallback { message, .. }) = item {
3638                got_fallback = true;
3639                let has_tool = message
3640                    .parts
3641                    .iter()
3642                    .any(|p| matches!(p, crate::message::MessagePart::ToolCall { name, .. } if name == "search"));
3643                assert!(
3644                    has_tool,
3645                    "fallback message must preserve the tool-call part, got: {:?}",
3646                    message.parts
3647                );
3648                let has_text = message
3649                    .parts
3650                    .iter()
3651                    .any(|p| matches!(p, crate::message::MessagePart::Text { text } if text == "Let me search"));
3652                assert!(has_text, "fallback message must preserve the text part");
3653            }
3654        }
3655        assert!(got_fallback, "must emit a Fallback event");
3656    }
3657
3658    #[tokio::test]
3659    async fn rate_limit_hard_stop_tries_fallback_when_enabled() {
3660        struct RateLimitThenOkMock;
3661        impl ApiClient for RateLimitThenOkMock {
3662            fn model(&self) -> String {
3663                "test".to_string()
3664            }
3665
3666            fn stream_messages(
3667                &self,
3668                _request: &crate::api::StreamRequest,
3669            ) -> std::pin::Pin<
3670                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3671            > {
3672                Box::pin(futures::stream::once(async {
3673                    Err(ApiError::RateLimit {
3674                        retry_after: None,
3675                        message: "slow down".into(),
3676                    })
3677                }))
3678            }
3679
3680            fn create_message(
3681                &self,
3682                _request: &crate::api::StreamRequest,
3683            ) -> std::pin::Pin<
3684                Box<
3685                    dyn std::future::Future<
3686                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
3687                        > + Send
3688                        + '_,
3689                >,
3690            > {
3691                Box::pin(async {
3692                    Ok(crate::api::NonStreamingResponse {
3693                        message: crate::message::Message::assistant("fallback ok"),
3694                        stop_reason: crate::stream::StreamStopReason::EndTurn,
3695                        usage: Some(crate::stream::Usage::default()),
3696                    })
3697                })
3698            }
3699        }
3700
3701        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
3702            fallback_after_retries: 2,
3703            max_retries: 2,
3704            default_delay: Duration::from_millis(1),
3705            max_delay: Duration::from_millis(1),
3706            ..Default::default()
3707        });
3708        let cancel = Arc::new(CancelSignal::new());
3709        let req = crate::api::StreamRequest::new(vec![]);
3710        let result = handler
3711            .drive_turn(&RateLimitThenOkMock, &req, &cancel)
3712            .await;
3713        assert!(
3714            result.is_ok(),
3715            "hard-stop must try fallback when enabled, got: {:?}",
3716            result.err()
3717        );
3718        let drive = result.unwrap();
3719        assert!(drive.from_fallback);
3720        assert!(drive.message.text_content().contains("fallback ok"));
3721    }
3722
3723    /// Mock that fails its first streaming attempt with a transport error,
3724    /// then succeeds on the second. Used by the AttemptReset test to verify
3725    /// the handler emits `AttemptReset` before the retried attempt's events.
3726    struct RetryingMock {
3727        attempts: Arc<std::sync::atomic::AtomicUsize>,
3728    }
3729
3730    impl ApiClient for RetryingMock {
3731        fn model(&self) -> String {
3732            "retry-test".to_string()
3733        }
3734        fn base_url(&self) -> String {
3735            "retry-test".to_string()
3736        }
3737        fn set_model(&self, _: &str) -> bool {
3738            false
3739        }
3740        fn stream_messages(
3741            &self,
3742            request: &crate::api::StreamRequest,
3743        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
3744            self.stream_messages_with_options(request, crate::structured::RequestOptions::default())
3745        }
3746        fn create_message(
3747            &self,
3748            request: &crate::api::StreamRequest,
3749        ) -> Pin<
3750            Box<
3751                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3752                    + Send
3753                    + '_,
3754            >,
3755        > {
3756            self.create_message_with_options(request, crate::structured::RequestOptions::default())
3757        }
3758        fn stream_messages_with_options(
3759            &self,
3760            _request: &crate::api::StreamRequest,
3761            _options: crate::structured::RequestOptions,
3762        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
3763            use std::sync::atomic::Ordering;
3764            let n = self.attempts.fetch_add(1, Ordering::SeqCst);
3765            if n == 0 {
3766                // First attempt: one event then transport error.
3767                Box::pin(futures::stream::iter(vec![
3768                    Ok(StreamEvent::MessageStart(MessageStart {
3769                        message: MessageMetadata {
3770                            id: String::new(),
3771                            role: "assistant".into(),
3772                            model: String::new(),
3773                        },
3774                    })),
3775                    Err(ApiError::api("transient")),
3776                ]))
3777            } else {
3778                // Second attempt: clean happy-path events.
3779                Box::pin(futures::stream::iter(happy_stream_events()))
3780            }
3781        }
3782        fn create_message_with_options(
3783            &self,
3784            _request: &crate::api::StreamRequest,
3785            _options: crate::structured::RequestOptions,
3786        ) -> Pin<
3787            Box<
3788                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
3789                    + Send
3790                    + '_,
3791            >,
3792        > {
3793            Box::pin(async {
3794                Ok(crate::api::NonStreamingResponse {
3795                    message: crate::message::Message::assistant(""),
3796                    stop_reason: crate::stream::StreamStopReason::EndTurn,
3797                    usage: Some(crate::stream::Usage::default()),
3798                })
3799            })
3800        }
3801        fn extract_structured(&self, _: &crate::message::Message) -> serde_json::Value {
3802            serde_json::Value::Null
3803        }
3804    }
3805
3806    #[tokio::test]
3807    async fn clean_first_attempt_emits_no_attempt_reset() {
3808        // The reset signal exists to discard partial state between
3809        // attempts; a clean first attempt must stay silent — consumers
3810        // reset on every occurrence.
3811        struct OneShotMock;
3812        impl ApiClient for OneShotMock {
3813            fn model(&self) -> String {
3814                "one-shot".to_string()
3815            }
3816            fn base_url(&self) -> String {
3817                "one-shot".to_string()
3818            }
3819            fn set_model(&self, _: &str) -> bool {
3820                false
3821            }
3822            fn stream_messages(
3823                &self,
3824                request: &crate::api::StreamRequest,
3825            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
3826            {
3827                self.stream_messages_with_options(
3828                    request,
3829                    crate::structured::RequestOptions::default(),
3830                )
3831            }
3832            fn create_message(
3833                &self,
3834                request: &crate::api::StreamRequest,
3835            ) -> Pin<
3836                Box<
3837                    dyn std::future::Future<
3838                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
3839                        > + Send
3840                        + '_,
3841                >,
3842            > {
3843                self.create_message_with_options(
3844                    request,
3845                    crate::structured::RequestOptions::default(),
3846                )
3847            }
3848            fn stream_messages_with_options(
3849                &self,
3850                _request: &crate::api::StreamRequest,
3851                _options: crate::structured::RequestOptions,
3852            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
3853            {
3854                Box::pin(futures::stream::iter(happy_stream_events()))
3855            }
3856            fn create_message_with_options(
3857                &self,
3858                _request: &crate::api::StreamRequest,
3859                _options: crate::structured::RequestOptions,
3860            ) -> Pin<
3861                Box<
3862                    dyn std::future::Future<
3863                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
3864                        > + Send
3865                        + '_,
3866                >,
3867            > {
3868                Box::pin(async {
3869                    Ok(crate::api::NonStreamingResponse {
3870                        message: crate::message::Message::assistant(""),
3871                        stop_reason: crate::stream::StreamStopReason::EndTurn,
3872                        usage: Some(crate::stream::Usage::default()),
3873                    })
3874                })
3875            }
3876            fn extract_structured(&self, _: &crate::message::Message) -> serde_json::Value {
3877                serde_json::Value::Null
3878            }
3879        }
3880
3881        let handler = StreamHandler::new();
3882        let cancel = Arc::new(CancelSignal::new());
3883        let req = crate::api::StreamRequest::new(vec![]);
3884        let mut stream = handler.stream_turn(
3885            &OneShotMock,
3886            &req,
3887            crate::structured::RequestOptions::default(),
3888            &cancel,
3889        );
3890        let mut events_seen = 0usize;
3891        while let Some(item) = stream.next().await {
3892            events_seen += 1;
3893            assert!(
3894                !matches!(item.expect("clean stream item"), HandlerEvent::AttemptReset),
3895                "a clean first attempt never announces a reset"
3896            );
3897        }
3898        assert!(
3899            events_seen > 0,
3900            "the silence assertion only counts on a stream that produced events"
3901        );
3902    }
3903
3904    #[tokio::test]
3905    async fn stream_turn_yields_attempt_reset_on_retry() {
3906        // A retried transport failure must emit AttemptReset before the
3907        // retried attempt's events. Engine uses this to discard partial state.
3908        use std::sync::atomic::AtomicUsize;
3909
3910        let attempts = Arc::new(AtomicUsize::new(0));
3911        let handler = StreamHandler::new();
3912        let client = RetryingMock { attempts };
3913        let cancel = Arc::new(CancelSignal::new());
3914
3915        let req = crate::api::StreamRequest::new(vec![]);
3916        let mut stream = handler.stream_turn(
3917            &client,
3918            &req,
3919            crate::structured::RequestOptions::default(),
3920            &cancel,
3921        );
3922        let mut saw_attempt_reset = false;
3923        while let Some(item) = stream.next().await {
3924            if let HandlerEvent::AttemptReset = item.expect("stream item ok") {
3925                saw_attempt_reset = true;
3926            }
3927        }
3928        assert!(
3929            saw_attempt_reset,
3930            "second attempt must be preceded by AttemptReset"
3931        );
3932    }
3933
3934    #[tokio::test]
3935    async fn stream_turn_happy_path() {
3936        let handler = StreamHandler::new();
3937        let client = HandlerMock::new().with_text_response("hello world");
3938        let cancel = Arc::new(CancelSignal::new());
3939
3940        let result = handler
3941            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
3942            .await
3943            .expect("stream_turn should succeed");
3944
3945        assert!(!result.from_fallback);
3946        assert_eq!(result.stop_reason, StreamStopReason::EndTurn);
3947    }
3948
3949    #[tokio::test]
3950    async fn stream_turn_cancelled_at_start() {
3951        let handler = StreamHandler::new();
3952        let client = HandlerMock::new().with_text_response("hello");
3953        let cancel = Arc::new(CancelSignal::new());
3954        cancel.cancel();
3955
3956        let err = handler
3957            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
3958            .await
3959            .expect_err("should fail on cancellation");
3960
3961        assert!(
3962            matches!(err, StreamHandlerError::Cancelled),
3963            "expected Cancelled, got: {err}"
3964        );
3965    }
3966
3967    #[tokio::test]
3968    async fn stream_turn_fallback_after_stream_error() {
3969        // When streaming fails but fallback is enabled, the handler
3970        // should fall back to create_message. We use two responses
3971        // queued: the first stream errors mid-way (we inject an error
3972        // event), and create_message gets the second response.
3973        //
3974        // However, MockApiClient::with_error blocks both paths, so we
3975        // test the fallback path directly via fallback_non_streaming
3976        // (covered above). Here we test that stream_turn returns the
3977        // error when streaming fails and the handler is configured
3978        // without fallback.
3979        struct ErrorMock;
3980        impl ApiClient for ErrorMock {
3981            fn model(&self) -> String {
3982                "test-model".to_string()
3983            }
3984            fn stream_messages(
3985                &self,
3986                _request: &crate::api::StreamRequest,
3987            ) -> std::pin::Pin<
3988                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
3989            > {
3990                Box::pin(futures::stream::once(async {
3991                    Err(ApiError::api("API down"))
3992                }))
3993            }
3994            fn create_message(
3995                &self,
3996                _request: &crate::api::StreamRequest,
3997            ) -> std::pin::Pin<
3998                Box<
3999                    dyn std::future::Future<
4000                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
4001                        > + Send
4002                        + '_,
4003                >,
4004            > {
4005                Box::pin(async { Err(ApiError::api("unreachable")) })
4006            }
4007        }
4008
4009        let handler = StreamHandler::new()
4010            .with_timeout_config(StreamTimeoutConfig {
4011                fallback_to_non_streaming: false,
4012                ..Default::default()
4013            })
4014            .with_retry_config(StreamRetryConfig {
4015                max_retries: 0,
4016                ..Default::default()
4017            });
4018
4019        let client = ErrorMock;
4020        let cancel = Arc::new(CancelSignal::new());
4021
4022        let err = handler
4023            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4024            .await
4025            .expect_err("should fail when streaming errors and fallback is disabled");
4026
4027        // With max_retries=0, we get 1 attempt. Error stream produces StreamFailed.
4028        match err {
4029            StreamHandlerError::StreamFailed(outcome) => {
4030                let s = outcome.to_string();
4031                assert!(s.contains("API down"), "unexpected: {s}");
4032            }
4033            other => panic!("expected StreamFailed, got: {other}"),
4034        }
4035    }
4036
4037    /// Mock that always fails streaming but succeeds on non-streaming
4038    /// `create_message`. Used by the fallback regression test to verify the
4039    /// handler yields `HandlerEvent::Fallback` with the message and stop_reason
4040    /// extracted from the JSON response.
4041    struct StreamingFailingFallbackMock;
4042    impl ApiClient for StreamingFailingFallbackMock {
4043        fn model(&self) -> String {
4044            "fallback-test".to_string()
4045        }
4046        fn base_url(&self) -> String {
4047            "fallback-test".to_string()
4048        }
4049        fn set_model(&self, _: &str) -> bool {
4050            false
4051        }
4052        fn stream_messages(
4053            &self,
4054            _request: &crate::api::StreamRequest,
4055        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
4056            // Always fail streaming — forces the handler to retry, then
4057            // fall back to create_message.
4058            Box::pin(futures::stream::once(async {
4059                Err(ApiError::api("stream down"))
4060            }))
4061        }
4062        fn create_message(
4063            &self,
4064            _request: &crate::api::StreamRequest,
4065        ) -> Pin<
4066            Box<
4067                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
4068                    + Send
4069                    + '_,
4070            >,
4071        > {
4072            Box::pin(async {
4073                Ok(crate::api::NonStreamingResponse {
4074                    message: crate::message::Message::assistant("fallback answer"),
4075                    stop_reason: crate::stream::StreamStopReason::MaxTokens,
4076                    usage: Some(crate::stream::Usage::new(42, 13)),
4077                })
4078            })
4079        }
4080        fn stream_messages_with_options(
4081            &self,
4082            _request: &crate::api::StreamRequest,
4083            _options: crate::structured::RequestOptions,
4084        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
4085            Box::pin(futures::stream::once(async {
4086                Err(ApiError::api("stream down"))
4087            }))
4088        }
4089        fn create_message_with_options(
4090            &self,
4091            _request: &crate::api::StreamRequest,
4092            _options: crate::structured::RequestOptions,
4093        ) -> Pin<
4094            Box<
4095                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
4096                    + Send
4097                    + '_,
4098            >,
4099        > {
4100            Box::pin(async {
4101                Ok(crate::api::NonStreamingResponse {
4102                    message: crate::message::Message::assistant("fallback answer"),
4103                    stop_reason: crate::stream::StreamStopReason::MaxTokens,
4104                    usage: Some(crate::stream::Usage::new(42, 13)),
4105                })
4106            })
4107        }
4108        fn extract_structured(&self, _: &crate::message::Message) -> serde_json::Value {
4109            serde_json::Value::Null
4110        }
4111    }
4112
4113    #[tokio::test]
4114    async fn drive_turn_returns_fallback_message_and_stop_reason() {
4115        // When streaming fails after retries and fallback is enabled, the
4116        // engine should see the fallback message and the stop_reason from the
4117        // non-streaming JSON response (not the streaming accumulator's stale
4118        // values). Regression test for an earlier bug where Fallback dropped
4119        // stop_reason.
4120        let handler = StreamHandler::new()
4121            .with_timeout_config(StreamTimeoutConfig {
4122                fallback_to_non_streaming: true,
4123                ..Default::default()
4124            })
4125            .with_retry_config(StreamRetryConfig {
4126                max_retries: 0,
4127                ..Default::default()
4128            });
4129        let client = StreamingFailingFallbackMock;
4130        let cancel = Arc::new(CancelSignal::new());
4131
4132        let result = handler
4133            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4134            .await
4135            .expect("fallback should succeed");
4136
4137        assert!(
4138            result.from_fallback,
4139            "result should be marked from_fallback"
4140        );
4141        // Stop reason comes from the JSON's "max_tokens", not the streaming
4142        // default "end_turn".
4143        assert_eq!(
4144            result.stop_reason,
4145            StreamStopReason::MaxTokens,
4146            "fallback stop_reason must come from the JSON response"
4147        );
4148        let text: String = result
4149            .message
4150            .parts
4151            .iter()
4152            .filter_map(|p| match p {
4153                crate::stream::MessagePart::Text { text } => Some(text.clone()),
4154                _ => None,
4155            })
4156            .collect();
4157        assert!(
4158            text.contains("fallback answer"),
4159            "fallback message text, got {text:?}"
4160        );
4161        assert_eq!(
4162            result.usage,
4163            Some(Usage::new(42, 13)),
4164            "fallback path must propagate usage from the non-streaming response"
4165        );
4166    }
4167
4168    #[test]
4169    fn rate_limit_config_default_values() {
4170        let cfg = RateLimitConfig::default();
4171        assert!(cfg.respect_retry_after);
4172        assert_eq!(cfg.default_delay, Duration::from_secs(5));
4173        assert_eq!(cfg.max_delay, Duration::from_mins(1));
4174        assert_eq!(cfg.requests_per_minute, 0);
4175        assert_eq!(cfg.fallback_after_retries, 3);
4176        assert_eq!(cfg.max_retries, 5);
4177    }
4178
4179    #[test]
4180    fn rate_limit_config_validate_rejects_invalid() {
4181        assert!(RateLimitConfig::default().validate().is_ok());
4182        assert!(
4183            RateLimitConfig {
4184                default_delay: Duration::ZERO,
4185                ..Default::default()
4186            }
4187            .validate()
4188            .is_err()
4189        );
4190        assert!(
4191            RateLimitConfig {
4192                max_delay: Duration::from_secs(1),
4193                default_delay: Duration::from_secs(10),
4194                ..Default::default()
4195            }
4196            .validate()
4197            .is_err()
4198        );
4199        assert!(
4200            RateLimitConfig {
4201                max_retries: 0,
4202                ..Default::default()
4203            }
4204            .validate()
4205            .is_err()
4206        );
4207    }
4208
4209    #[test]
4210    fn with_timeout_config_substitutes_only_invalid_fields() {
4211        let bad_total = StreamTimeoutConfig {
4212            initial_event_timeout: Duration::from_secs(45),
4213            per_event_timeout: Duration::from_secs(45),
4214            total_stream_timeout: Duration::MAX,
4215            max_consecutive_timeouts: 7,
4216            ..Default::default()
4217        };
4218        let handler = StreamHandler::new().with_timeout_config(bad_total);
4219        let config = handler.timeout_config();
4220        assert_eq!(
4221            config.initial_event_timeout,
4222            Duration::from_secs(45),
4223            "valid fields the caller supplied must survive an invalid sibling"
4224        );
4225        assert_eq!(config.per_event_timeout, Duration::from_secs(45));
4226        assert_eq!(config.max_consecutive_timeouts, 7);
4227        assert_eq!(
4228            config.total_stream_timeout,
4229            StreamTimeoutConfig::default().total_stream_timeout,
4230            "an infinite total timeout is substituted with the default, not silently disabling every deadline"
4231        );
4232
4233        let unordered = StreamTimeoutConfig {
4234            initial_event_timeout: Duration::from_secs(400),
4235            ..Default::default()
4236        };
4237        let handler = StreamHandler::new().with_timeout_config(unordered);
4238        assert_eq!(
4239            handler.timeout_config().total_stream_timeout,
4240            Duration::from_secs(400),
4241            "a default total below a custom initial timeout is raised to it, \
4242             keeping the caller's initial customization"
4243        );
4244    }
4245
4246    #[test]
4247    fn sanitized_config_always_validates() {
4248        let adversarial = [
4249            StreamTimeoutConfig {
4250                initial_event_timeout: Duration::from_secs(600),
4251                total_stream_timeout: Duration::ZERO,
4252                ..Default::default()
4253            },
4254            StreamTimeoutConfig {
4255                initial_event_timeout: Duration::MAX,
4256                ..Default::default()
4257            },
4258            StreamTimeoutConfig {
4259                per_event_timeout: Duration::MAX,
4260                ..Default::default()
4261            },
4262            StreamTimeoutConfig {
4263                initial_event_timeout: Duration::from_secs(45),
4264                per_event_timeout: Duration::from_secs(45),
4265                total_stream_timeout: Duration::MAX,
4266                max_consecutive_timeouts: 7,
4267                ..Default::default()
4268            },
4269        ];
4270        for config in adversarial {
4271            let handler = StreamHandler::new().with_timeout_config(config);
4272            assert!(
4273                handler.timeout_config().validate().is_ok(),
4274                "the sanitized builder output must satisfy every validate rule: {:?}",
4275                handler.timeout_config()
4276            );
4277        }
4278        let zero_total = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
4279            initial_event_timeout: Duration::from_secs(600),
4280            total_stream_timeout: Duration::ZERO,
4281            ..Default::default()
4282        });
4283        assert_eq!(
4284            zero_total.timeout_config().total_stream_timeout,
4285            Duration::from_secs(600),
4286            "a repaired total must still honor the ordering rule against a large initial"
4287        );
4288    }
4289
4290    #[test]
4291    fn handler_error_display_never_says_init_failed() {
4292        let outcome = StreamOutcome::InitFailed {
4293            last_error: "stream ended without a terminal event".to_string(),
4294            attempts: 3,
4295        };
4296        let rendered = StreamHandlerError::InitFailed(outcome).to_string();
4297        assert!(
4298            rendered.contains("without a terminal event"),
4299            "the wrapper names the failure cause: {rendered}"
4300        );
4301        assert!(
4302            !rendered.contains("init failed"),
4303            "the historical variant name must not leak into the rendered message: {rendered}"
4304        );
4305    }
4306
4307    #[test]
4308    fn with_timeout_config_keeps_valid() {
4309        let good = StreamTimeoutConfig {
4310            initial_event_timeout: Duration::from_secs(45),
4311            ..Default::default()
4312        };
4313        let handler = StreamHandler::new().with_timeout_config(good);
4314        assert_eq!(
4315            handler.timeout_config().initial_event_timeout,
4316            Duration::from_secs(45)
4317        );
4318    }
4319
4320    #[test]
4321    fn with_retry_config_rejects_invalid_falls_back_to_default() {
4322        let bad = StreamRetryConfig {
4323            base_delay_ms: 0,
4324            ..Default::default()
4325        };
4326        let handler = StreamHandler::new().with_retry_config(bad);
4327        assert_eq!(
4328            handler.retry_config().base_delay_ms,
4329            StreamRetryConfig::default().base_delay_ms,
4330            "invalid retry config must fall back to default"
4331        );
4332    }
4333
4334    #[test]
4335    fn with_retry_config_keeps_valid() {
4336        let good = StreamRetryConfig {
4337            max_retries: 7,
4338            ..Default::default()
4339        };
4340        let handler = StreamHandler::new().with_retry_config(good);
4341        assert_eq!(handler.retry_config().max_retries, 7);
4342    }
4343
4344    #[test]
4345    fn with_timeout_and_retry_config_are_independent() {
4346        let good_timeout = StreamTimeoutConfig {
4347            initial_event_timeout: Duration::from_mins(1),
4348            ..Default::default()
4349        };
4350        let bad_retry = StreamRetryConfig {
4351            jitter_factor: 2.0,
4352            ..Default::default()
4353        };
4354        let handler = StreamHandler::new()
4355            .with_timeout_config(good_timeout)
4356            .with_retry_config(bad_retry);
4357        assert_eq!(
4358            handler.timeout_config().initial_event_timeout,
4359            Duration::from_mins(1),
4360            "valid timeout must be kept when retry config is invalid"
4361        );
4362        assert_eq!(
4363            handler.retry_config().max_retries,
4364            StreamRetryConfig::default().max_retries,
4365            "invalid retry config must fall back to default"
4366        );
4367    }
4368
4369    #[test]
4370    fn with_rate_limit_config_rejects_invalid_falls_back_to_default() {
4371        let bad = RateLimitConfig {
4372            max_retries: 0,
4373            ..Default::default()
4374        };
4375        let handler = StreamHandler::new().with_rate_limit_config(bad);
4376        assert_eq!(
4377            handler.rate_limit_config().max_retries,
4378            RateLimitConfig::default().max_retries,
4379            "invalid rate-limit config must fall back to default"
4380        );
4381    }
4382
4383    #[test]
4384    fn rate_limit_config_backoff_honours_hint_and_caps() {
4385        let cfg = RateLimitConfig::default();
4386        assert_eq!(
4387            cfg.backoff(Some(Duration::from_secs(12))),
4388            Duration::from_secs(12)
4389        );
4390        assert_eq!(
4391            cfg.backoff(Some(Duration::from_mins(2))),
4392            cfg.max_delay,
4393            "should cap at max_delay"
4394        );
4395        assert_eq!(cfg.backoff(None), cfg.default_delay);
4396
4397        let ignore = RateLimitConfig {
4398            respect_retry_after: false,
4399            ..Default::default()
4400        };
4401        assert_eq!(
4402            ignore.backoff(Some(Duration::from_secs(12))),
4403            ignore.default_delay
4404        );
4405    }
4406
4407    #[test]
4408    fn clamp_delay_to_deadline_none_deadline_returns_delay_unchanged() {
4409        let delay = Duration::from_mins(10);
4410        assert_eq!(clamp_delay_to_deadline(delay, None), delay);
4411    }
4412
4413    #[test]
4414    fn clamp_delay_to_deadline_future_deadline_fits() {
4415        let delay = Duration::from_millis(10);
4416        let deadline = Some(Instant::now() + Duration::from_mins(1));
4417        assert_eq!(clamp_delay_to_deadline(delay, deadline), delay);
4418    }
4419
4420    #[test]
4421    fn clamp_delay_to_deadline_exceeds_remaining() {
4422        let delay = Duration::from_mins(10);
4423        let remaining = Duration::from_millis(50);
4424        let deadline = Some(Instant::now() + remaining);
4425        let clamped = clamp_delay_to_deadline(delay, deadline);
4426        assert!(
4427            clamped <= remaining,
4428            "clamped {clamped:?} must not exceed remaining {remaining:?}"
4429        );
4430        assert!(
4431            !clamped.is_zero(),
4432            "deadline still in the future, so sleep should be positive"
4433        );
4434    }
4435
4436    #[test]
4437    fn clamp_delay_to_deadline_past_deadline_zero() {
4438        let delay = Duration::from_mins(10);
4439        let deadline = Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap());
4440        assert_eq!(clamp_delay_to_deadline(delay, deadline), Duration::ZERO);
4441    }
4442
4443    #[test]
4444    fn backoff_clamps_huge_hint_to_max_delay() {
4445        let cfg = RateLimitConfig {
4446            max_delay: Duration::from_mins(1),
4447            ..Default::default()
4448        };
4449        assert_eq!(
4450            cfg.backoff(Some(Duration::from_secs(9_999_999))),
4451            Duration::from_mins(1)
4452        );
4453    }
4454
4455    fn detected_limit(retry_after: Option<Duration>) -> DetectedRateLimit {
4456        DetectedRateLimit {
4457            kind: RateLimitKind::RateLimited,
4458            retry_after,
4459            message: "slow down".to_string(),
4460        }
4461    }
4462
4463    #[test]
4464    fn rate_limit_retry_returns_clamped_delay_below_ceilings() {
4465        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
4466            fallback_after_retries: 3,
4467            max_retries: 5,
4468            default_delay: Duration::from_millis(1),
4469            max_delay: Duration::from_mins(1),
4470            ..Default::default()
4471        });
4472        let mut count = 0u32;
4473        let detail = detected_limit(Some(Duration::from_mins(10)));
4474
4475        // Below both ceilings: retry with the hint clamped to max_delay.
4476        let decision = handler.rate_limit_retry(&detail, &mut count, None);
4477        assert_eq!(count, 1);
4478        match decision {
4479            RateLimitRetry::Retry(delay) => assert_eq!(delay, Duration::from_mins(1)),
4480            other => panic!("expected Retry, got {other:?}"),
4481        }
4482    }
4483
4484    #[test]
4485    fn rate_limit_retry_escalates_after_fallback_ceiling() {
4486        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
4487            fallback_after_retries: 2,
4488            max_retries: 5,
4489            ..Default::default()
4490        });
4491        let mut count = 0u32;
4492        let detail = detected_limit(Some(Duration::from_millis(5)));
4493
4494        // Two retries are honored, then the next hit escalates.
4495        let _ = handler.rate_limit_retry(&detail, &mut count, None);
4496        let _ = handler.rate_limit_retry(&detail, &mut count, None);
4497        assert_eq!(count, 2);
4498        let decision = handler.rate_limit_retry(&detail, &mut count, None);
4499        assert_eq!(count, 3);
4500        match decision {
4501            RateLimitRetry::Escalate {
4502                attempts,
4503                retry_after,
4504            } => {
4505                assert_eq!(attempts, 3);
4506                assert_eq!(retry_after, Some(Duration::from_millis(5)));
4507            }
4508            other => panic!("expected Escalate, got {other:?}"),
4509        }
4510    }
4511
4512    #[test]
4513    fn rate_limit_retry_hard_stops_after_max_retries() {
4514        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
4515            fallback_after_retries: 1,
4516            max_retries: 2,
4517            ..Default::default()
4518        });
4519        let mut count = 0u32;
4520        let detail = detected_limit(None);
4521
4522        let _ = handler.rate_limit_retry(&detail, &mut count, None);
4523        let _ = handler.rate_limit_retry(&detail, &mut count, None);
4524        assert_eq!(count, 2);
4525        assert!(matches!(
4526            handler.rate_limit_retry(&detail, &mut count, None),
4527            RateLimitRetry::HardStop
4528        ));
4529        assert_eq!(count, 3);
4530    }
4531
4532    #[test]
4533    fn rate_limit_retry_max_retries_should_be_enforced_under_valid_config() {
4534        let handler = StreamHandler::new();
4535        let detail = detected_limit(None);
4536        let mut count = 0u32;
4537
4538        for _ in 0..(handler.rate_limit_config().max_retries + 2) {
4539            let _ = handler.rate_limit_retry(&detail, &mut count, None);
4540        }
4541        let max = handler.rate_limit_config().max_retries;
4542        assert!(
4543            count > max,
4544            "count {count} must exceed max_retries {max} after enough calls"
4545        );
4546        let decision = handler.rate_limit_retry(&detail, &mut count, None);
4547        assert!(
4548            matches!(decision, RateLimitRetry::HardStop),
4549            "max_retries={max} should be enforced as a hard ceiling, \
4550             but Escalate shadows it — HardStop is dead code under valid config"
4551        );
4552    }
4553
4554    #[test]
4555    fn with_rate_limit_config_should_reject_invalid() {
4556        let invalid = RateLimitConfig {
4557            fallback_after_retries: 10,
4558            max_retries: 3,
4559            ..Default::default()
4560        };
4561        let result = StreamHandler::new().with_rate_limit_config(invalid);
4562        let detail = detected_limit(None);
4563        let mut count = 0u32;
4564        for _ in 0..4 {
4565            let _ = result.rate_limit_retry(&detail, &mut count, None);
4566        }
4567        let decision = result.rate_limit_retry(&detail, &mut count, None);
4568        assert!(
4569            !matches!(decision, RateLimitRetry::HardStop),
4570            "invalid config (fallback_after=10 > max_retries=3) must not \
4571             silently invert behavior — HardStop should never fire before Escalation"
4572        );
4573    }
4574
4575    #[test]
4576    fn detected_rate_limit_from_structured_variant() {
4577        let err = ApiError::RateLimit {
4578            retry_after: Some(Duration::from_secs(7)),
4579            message: "slow down".into(),
4580        };
4581        let detected = DetectedRateLimit::detect(&err).expect("RateLimit variant should detect");
4582        assert_eq!(detected.kind, RateLimitKind::RateLimited);
4583        assert_eq!(detected.retry_after, Some(Duration::from_secs(7)));
4584    }
4585
4586    #[test]
4587    fn detected_rate_limit_from_structured_variant_no_hint() {
4588        let err = ApiError::RateLimit {
4589            retry_after: None,
4590            message: "slow down".into(),
4591        };
4592        let detected = DetectedRateLimit::detect(&err).expect("RateLimit variant should detect");
4593        assert_eq!(detected.retry_after, None);
4594    }
4595
4596    #[test]
4597    fn detected_rate_limit_from_http_503() {
4598        let err = ApiError::http_with_status(503, "overloaded");
4599        let detected = DetectedRateLimit::detect(&err).expect("503 should detect as Overloaded");
4600        assert_eq!(detected.kind, RateLimitKind::Overloaded);
4601    }
4602
4603    #[test]
4604    fn detected_rate_limit_http_500_is_not_overload() {
4605        let err = ApiError::http_with_status(500, "boom");
4606        assert!(DetectedRateLimit::detect(&err).is_none());
4607    }
4608
4609    #[test]
4610    fn detected_rate_limit_non_rate_errors_return_none() {
4611        assert!(DetectedRateLimit::detect(&ApiError::api("connection reset")).is_none());
4612        assert!(DetectedRateLimit::detect(&ApiError::auth("bad key")).is_none());
4613    }
4614
4615    #[test]
4616    fn is_rate_limited_matches_detect() {
4617        let cases: &[ApiError] = &[
4618            ApiError::RateLimit {
4619                retry_after: None,
4620                message: "x".into(),
4621            },
4622            ApiError::http_with_status(503, "overloaded"),
4623            ApiError::http_with_status(500, "boom"),
4624            ApiError::api("connection reset"),
4625            ApiError::auth("bad key"),
4626        ];
4627        for err in cases {
4628            assert_eq!(
4629                err.is_rate_limited(),
4630                DetectedRateLimit::detect(err).is_some(),
4631                "is_rate_limited disagree with detect on {err}",
4632            );
4633        }
4634    }
4635
4636    #[test]
4637    fn stream_outcome_rate_limited_display() {
4638        let outcome = StreamOutcome::RateLimited {
4639            detail: DetectedRateLimit {
4640                kind: RateLimitKind::RateLimited,
4641                retry_after: Some(Duration::from_secs(12)),
4642                message: "slow down".into(),
4643            },
4644            has_partial_data: false,
4645            events_processed: 5,
4646        };
4647        let s = outcome.to_string();
4648        assert!(s.contains("rate limit"), "got: {s}");
4649        assert!(s.contains("12"), "retry-after seconds missing: {s}");
4650    }
4651
4652    #[test]
4653    fn stream_handler_rate_limit_config_round_trip() {
4654        let handler = StreamHandler::new();
4655        assert_eq!(
4656            handler.rate_limit_config().max_retries,
4657            RateLimitConfig::default().max_retries
4658        );
4659
4660        let custom = RateLimitConfig {
4661            max_retries: 2,
4662            fallback_after_retries: 1,
4663            default_delay: Duration::from_secs(1),
4664            ..Default::default()
4665        };
4666        let handler = StreamHandler::new().with_rate_limit_config(custom);
4667        assert_eq!(handler.rate_limit_config().max_retries, 2);
4668        assert_eq!(
4669            handler.rate_limit_config().default_delay,
4670            Duration::from_secs(1)
4671        );
4672    }
4673
4674    struct GateMock {
4675        url: &'static str,
4676    }
4677
4678    impl ApiClient for GateMock {
4679        fn model(&self) -> String {
4680            "gate-model".to_string()
4681        }
4682        fn base_url(&self) -> String {
4683            self.url.to_string()
4684        }
4685        fn stream_messages(
4686            &self,
4687            _request: &crate::api::StreamRequest,
4688        ) -> std::pin::Pin<
4689            Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
4690        > {
4691            Box::pin(futures::stream::iter(happy_stream_events()))
4692        }
4693        fn create_message(
4694            &self,
4695            _request: &crate::api::StreamRequest,
4696        ) -> std::pin::Pin<
4697            Box<
4698                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
4699                    + Send
4700                    + '_,
4701            >,
4702        > {
4703            Box::pin(async {
4704                Ok(crate::api::NonStreamingResponse {
4705                    message: crate::message::Message::assistant(""),
4706                    stop_reason: crate::stream::StreamStopReason::EndTurn,
4707                    usage: Some(crate::stream::Usage::default()),
4708                })
4709            })
4710        }
4711    }
4712
4713    #[tokio::test]
4714    async fn gate_on_rate_limit_noop_without_limiter() {
4715        // Default handler has no limiter — the gate must return Ok immediately
4716        // and never touch a bucket.
4717        let handler = StreamHandler::new();
4718        let client = GateMock { url: "openai" };
4719        let cancel = Arc::new(CancelSignal::new());
4720        let result = handler.gate_on_rate_limit(&client, &cancel, None).await;
4721        assert!(result.is_ok(), "no limiter => gate is a no-op");
4722    }
4723
4724    #[tokio::test]
4725    async fn gate_on_rate_limit_full_bucket_acquires_immediately() {
4726        // A fresh bucket is full, so the first acquire should return Ok without
4727        // any observable wait.
4728        use crate::stream::rate_limit::RateLimiter;
4729        let limiter = Arc::new(RateLimiter::new(60));
4730        let handler = StreamHandler::new().with_rate_limiter(Arc::clone(&limiter));
4731        let client = GateMock { url: "openai" };
4732        let cancel = Arc::new(CancelSignal::new());
4733
4734        let start = Instant::now();
4735        handler
4736            .gate_on_rate_limit(&client, &cancel, None)
4737            .await
4738            .expect("full bucket should acquire");
4739        let elapsed = start.elapsed();
4740        assert!(
4741            elapsed < Duration::from_millis(100),
4742            "full bucket should not wait; elapsed {elapsed:?}"
4743        );
4744    }
4745
4746    #[tokio::test]
4747    async fn gate_on_rate_limit_respects_total_deadline() {
4748        // A 1-RPM limiter with a generous max_wait (120s), so max_wait does NOT
4749        // fire first. Drain the single token, then call the gate with a
4750        // total_deadline already in the past: it must proceed immediately
4751        // (return Ok) rather than sleeping or spinning. The downstream
4752        // process_events deadline checks report the actual TotalTimeout — the
4753        // gate's job is just to not overrun the budget.
4754        use crate::stream::rate_limit::RateLimiter;
4755        let limiter = Arc::new(RateLimiter::new(1));
4756        let handler = StreamHandler::new()
4757            .with_rate_limiter(Arc::clone(&limiter))
4758            .with_rate_limit_max_wait(Duration::from_mins(2));
4759        let client = GateMock { url: "openai" };
4760        let cancel = Arc::new(CancelSignal::new());
4761
4762        // First acquire drains the only token.
4763        handler
4764            .gate_on_rate_limit(&client, &cancel, None)
4765            .await
4766            .expect("first acquire should succeed (full bucket)");
4767
4768        // Expired total deadline → proceed immediately, no overrun.
4769        let expired = Some(
4770            Instant::now()
4771                .checked_sub(Duration::from_secs(1))
4772                .unwrap_or(Instant::now()),
4773        );
4774        let start = Instant::now();
4775        handler
4776            .gate_on_rate_limit(&client, &cancel, expired)
4777            .await
4778            .expect("gate should proceed on an expired deadline, not hang or spin");
4779        let elapsed = start.elapsed();
4780        assert!(
4781            elapsed < Duration::from_millis(500),
4782            "gate should proceed immediately on an expired deadline; elapsed {elapsed:?}"
4783        );
4784    }
4785
4786    #[tokio::test]
4787    async fn gate_on_rate_limit_clamps_sleep_to_remaining_deadline() {
4788        // 1-RPM limiter (raw wait ~60s for a refill), max_wait 120s (so it does
4789        // NOT bind), but a total_deadline only ~80ms in the future. The gate
4790        // must clamp the sleep to the remaining deadline budget and proceed
4791        // after ~80ms — proving it honors the turn ceiling rather than the
4792        // 60s refill wait or the 120s max_wait.
4793        use crate::stream::rate_limit::RateLimiter;
4794        let limiter = Arc::new(RateLimiter::new(1));
4795        let handler = StreamHandler::new()
4796            .with_rate_limiter(Arc::clone(&limiter))
4797            .with_rate_limit_max_wait(Duration::from_mins(2));
4798        let client = GateMock { url: "openai" };
4799        let cancel = Arc::new(CancelSignal::new());
4800
4801        // First acquire drains the only token.
4802        handler
4803            .gate_on_rate_limit(&client, &cancel, None)
4804            .await
4805            .expect("first acquire should succeed (full bucket)");
4806
4807        // Tight-but-not-expired deadline: ~80ms remaining.
4808        let near_deadline = Some(
4809            Instant::now()
4810                .checked_add(Duration::from_millis(80))
4811                .unwrap_or(Instant::now()),
4812        );
4813        let start = Instant::now();
4814        handler
4815            .gate_on_rate_limit(&client, &cancel, near_deadline)
4816            .await
4817            .expect("gate should proceed after clamping to the deadline");
4818        let elapsed = start.elapsed();
4819        assert!(
4820            elapsed < Duration::from_secs(2),
4821            "gate should proceed within the ~80ms deadline window, not wait 60s; elapsed {elapsed:?}"
4822        );
4823    }
4824
4825    #[tokio::test]
4826    async fn proactive_throttle_slows_burst() {
4827        // 60 RPM → refill 1 token/sec, burst capacity 60. Fire 3 turns
4828        // back-to-back through stream_turn (end-to-end wiring).
4829        use crate::stream::rate_limit::RateLimiter;
4830        let limiter = Arc::new(RateLimiter::new(60));
4831        let handler = StreamHandler::new()
4832            .with_rate_limiter(Arc::clone(&limiter))
4833            .with_rate_limit_max_wait(Duration::from_mins(2));
4834        let client = GateMock { url: "openai" };
4835        let cancel = Arc::new(CancelSignal::new());
4836
4837        let start = Instant::now();
4838        for _ in 0..3 {
4839            handler
4840                .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4841                .await
4842                .expect("turn should succeed");
4843        }
4844        let elapsed = start.elapsed();
4845        assert!(
4846            elapsed < Duration::from_secs(5),
4847            "three turns from a 60-burst should be fast; elapsed {elapsed:?}"
4848        );
4849        assert!(limiter.is_enabled());
4850    }
4851
4852    #[tokio::test]
4853    async fn proactive_throttle_cancel_interrupts_wait() {
4854        // 1 RPM → after the first turn drains the single-token bucket, the
4855        // second turn must wait ~60s. Cancelling during that wait should return
4856        // promptly.
4857        use crate::stream::rate_limit::RateLimiter;
4858        let limiter = Arc::new(RateLimiter::new(1));
4859        let handler = StreamHandler::new()
4860            .with_rate_limiter(limiter)
4861            .with_rate_limit_max_wait(Duration::from_millis(50));
4862        let client = GateMock { url: "openai" };
4863
4864        // First turn consumes the only token.
4865        let cancel = Arc::new(CancelSignal::new());
4866        handler
4867            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4868            .await
4869            .expect("first turn should succeed");
4870
4871        // Cancel before the second turn — it will need to wait ~60s for a token.
4872        let cancel2 = Arc::new(CancelSignal::new());
4873        cancel2.cancel();
4874        let start = Instant::now();
4875        let err = handler
4876            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel2)
4877            .await
4878            .expect_err("should be cancelled, not hang for 60s");
4879        let elapsed = start.elapsed();
4880
4881        assert!(
4882            matches!(err, StreamHandlerError::Cancelled),
4883            "expected Cancelled, got {err:?}"
4884        );
4885        assert!(
4886            elapsed < Duration::from_secs(2),
4887            "cancel should interrupt the wait promptly; elapsed {elapsed:?}"
4888        );
4889    }
4890
4891    #[tokio::test]
4892    async fn proactive_throttle_max_wait_clamp_degrades_to_reactive() {
4893        // 1 RPM but max_wait = 50ms. The second turn must wait ~60s for a token,
4894        // but the clamp caps the cumulative wait at 50ms, so the turn proceeds.
4895        use crate::stream::rate_limit::RateLimiter;
4896        let limiter = Arc::new(RateLimiter::new(1));
4897        let handler = StreamHandler::new()
4898            .with_rate_limiter(limiter)
4899            .with_rate_limit_max_wait(Duration::from_millis(50));
4900        let client = GateMock { url: "openai" };
4901        let cancel = Arc::new(CancelSignal::new());
4902
4903        // First turn consumes the only token.
4904        handler
4905            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4906            .await
4907            .expect("first turn should succeed");
4908
4909        // Second turn: bucket empty, wait capped at 50ms, then proceeds.
4910        let start = Instant::now();
4911        let result = handler
4912            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4913            .await;
4914        let elapsed = start.elapsed();
4915
4916        assert!(
4917            result.is_ok(),
4918            "max_wait clamp should let the turn proceed, got {result:?}"
4919        );
4920        assert!(
4921            elapsed < Duration::from_secs(2),
4922            "should proceed after ~50ms, not wait 60s; elapsed {elapsed:?}"
4923        );
4924    }
4925
4926    #[tokio::test]
4927    async fn stream_turn_uses_rate_limit_delay_on_rate_limited_outcome() {
4928        use std::sync::atomic::{AtomicUsize, Ordering};
4929
4930        struct RateLimitOnceMock {
4931            attempts: AtomicUsize,
4932        }
4933        impl ApiClient for RateLimitOnceMock {
4934            fn model(&self) -> String {
4935                "test-model".to_string()
4936            }
4937            fn stream_messages(
4938                &self,
4939                _request: &crate::api::StreamRequest,
4940            ) -> std::pin::Pin<
4941                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
4942            > {
4943                let n = self.attempts.fetch_add(1, Ordering::SeqCst);
4944                if n == 0 {
4945                    Box::pin(futures::stream::once(async {
4946                        Err(ApiError::RateLimit {
4947                            retry_after: None,
4948                            message: "slow down".into(),
4949                        })
4950                    }))
4951                } else {
4952                    Box::pin(futures::stream::iter(happy_stream_events()))
4953                }
4954            }
4955            fn create_message(
4956                &self,
4957                _request: &crate::api::StreamRequest,
4958            ) -> std::pin::Pin<
4959                Box<
4960                    dyn std::future::Future<
4961                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
4962                        > + Send
4963                        + '_,
4964                >,
4965            > {
4966                Box::pin(async {
4967                    Ok(crate::api::NonStreamingResponse {
4968                        message: crate::message::Message::assistant(""),
4969                        stop_reason: crate::stream::StreamStopReason::EndTurn,
4970                        usage: Some(crate::stream::Usage::default()),
4971                    })
4972                })
4973            }
4974        }
4975
4976        // Rate-limit delay tiny; transport retry delay large. If the retry
4977        // loop honours the rate-limit outcome, the test finishes in ~1ms; if
4978        // it falls back to the transport base_delay, it sleeps 2s.
4979        let handler = StreamHandler::new().with_retry_config(StreamRetryConfig {
4980            max_retries: 1,
4981            base_delay_ms: 2_000,
4982            ..Default::default()
4983        });
4984        let handler = handler.with_rate_limit_config(RateLimitConfig {
4985            default_delay: Duration::from_millis(1),
4986            ..Default::default()
4987        });
4988        let client = RateLimitOnceMock {
4989            attempts: AtomicUsize::new(0),
4990        };
4991        let cancel = Arc::new(CancelSignal::new());
4992
4993        let start = Instant::now();
4994        let result = handler
4995            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4996            .await
4997            .expect("second attempt should succeed");
4998        let elapsed = start.elapsed();
4999
5000        assert!(!result.from_fallback);
5001        assert!(
5002            elapsed < Duration::from_secs(1),
5003            "rate-limit retry should use RateLimitConfig delay, not the 2s transport delay; elapsed {elapsed:?}",
5004        );
5005    }
5006
5007    #[tokio::test]
5008    async fn stream_turn_escalates_after_rate_limit_threshold() {
5009        // Every attempt is rate-limited, so the loop must escalate rather than
5010        // exhaust the generic transport budget.
5011        struct AlwaysRateLimitMock;
5012        impl ApiClient for AlwaysRateLimitMock {
5013            fn model(&self) -> String {
5014                "test-model".to_string()
5015            }
5016            fn stream_messages(
5017                &self,
5018                _request: &crate::api::StreamRequest,
5019            ) -> std::pin::Pin<
5020                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5021            > {
5022                Box::pin(futures::stream::once(async {
5023                    Err(ApiError::RateLimit {
5024                        retry_after: Some(Duration::from_millis(1)),
5025                        message: "slow down".into(),
5026                    })
5027                }))
5028            }
5029            fn create_message(
5030                &self,
5031                _request: &crate::api::StreamRequest,
5032            ) -> std::pin::Pin<
5033                Box<
5034                    dyn std::future::Future<
5035                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5036                        > + Send
5037                        + '_,
5038                >,
5039            > {
5040                Box::pin(async {
5041                    Ok(crate::api::NonStreamingResponse {
5042                        message: crate::message::Message::assistant(""),
5043                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5044                        usage: Some(crate::stream::Usage::default()),
5045                    })
5046                })
5047            }
5048        }
5049
5050        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
5051            fallback_after_retries: 2,
5052            default_delay: Duration::from_millis(1),
5053            max_delay: Duration::from_millis(1),
5054            ..Default::default()
5055        });
5056        let client = AlwaysRateLimitMock;
5057        let cancel = Arc::new(CancelSignal::new());
5058
5059        let err = handler
5060            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5061            .await
5062            .expect_err("should escalate, not succeed");
5063        match err {
5064            StreamHandlerError::RateLimitEscalation {
5065                attempts,
5066                retry_after,
5067            } => {
5068                // fallback_after_retries == 2, so escalation fires on the 3rd hit.
5069                assert_eq!(attempts, 3);
5070                assert_eq!(retry_after, Some(Duration::from_millis(1)));
5071            }
5072            other => panic!("expected RateLimitEscalation, got {other:?}"),
5073        }
5074    }
5075
5076    #[tokio::test]
5077    async fn default_rate_limit_config_escalates_without_a_non_streaming_attempt() {
5078        struct Counting429Mock {
5079            non_streaming_calls: std::sync::atomic::AtomicUsize,
5080        }
5081        impl ApiClient for Counting429Mock {
5082            fn model(&self) -> String {
5083                "test".to_string()
5084            }
5085            fn stream_messages(
5086                &self,
5087                _request: &crate::api::StreamRequest,
5088            ) -> std::pin::Pin<
5089                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5090            > {
5091                Box::pin(futures::stream::once(async {
5092                    Err(ApiError::RateLimit {
5093                        retry_after: None,
5094                        message: "slow down".into(),
5095                    })
5096                }))
5097            }
5098            fn create_message(
5099                &self,
5100                _request: &crate::api::StreamRequest,
5101            ) -> std::pin::Pin<
5102                Box<
5103                    dyn std::future::Future<
5104                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5105                        > + Send
5106                        + '_,
5107                >,
5108            > {
5109                self.non_streaming_calls
5110                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5111                Box::pin(async {
5112                    Ok(crate::api::NonStreamingResponse {
5113                        message: crate::message::Message::assistant("fallback ok"),
5114                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5115                        usage: Some(crate::stream::Usage::default()),
5116                    })
5117                })
5118            }
5119        }
5120
5121        let client = Counting429Mock {
5122            non_streaming_calls: std::sync::atomic::AtomicUsize::new(0),
5123        };
5124        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
5125            default_delay: Duration::from_millis(1),
5126            max_delay: Duration::from_millis(1),
5127            ..Default::default()
5128        });
5129        let cancel = Arc::new(CancelSignal::new());
5130        let err = handler
5131            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5132            .await
5133            .expect_err("the default ladder escalates rather than exhausting");
5134        assert!(
5135            matches!(err, StreamHandlerError::RateLimitEscalation { .. }),
5136            "the default ladder (fallback_after=3 < max=5) escalates to the model \
5137             breaker, got: {err:?}"
5138        );
5139        assert_eq!(
5140            client
5141                .non_streaming_calls
5142                .load(std::sync::atomic::Ordering::SeqCst),
5143            0,
5144            "a rate limit is charged against the model's quota — a same-model \
5145             non-streaming request is deliberately not attempted (the ceiling-equal \
5146             ladder opts into it)"
5147        );
5148    }
5149
5150    #[tokio::test]
5151    async fn rate_limit_after_partial_data_reports_has_partial_data() {
5152        struct PartialThenRateLimitMock;
5153        impl ApiClient for PartialThenRateLimitMock {
5154            fn model(&self) -> String {
5155                "partial-then-429".to_string()
5156            }
5157            fn stream_messages(
5158                &self,
5159                _request: &crate::api::StreamRequest,
5160            ) -> std::pin::Pin<
5161                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5162            > {
5163                let events = vec![
5164                    Ok(StreamEvent::MessageStart(MessageStart {
5165                        message: MessageMetadata {
5166                            id: "m1".to_string(),
5167                            role: "assistant".to_string(),
5168                            model: "partial-then-429".to_string(),
5169                        },
5170                    })),
5171                    Ok(StreamEvent::PartStart(PartStart {
5172                        index: 0,
5173                        part: Some(crate::stream::MessagePart::text("")),
5174                    })),
5175                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
5176                        index: 0,
5177                        delta: DeltaPart::Text {
5178                            text: "partial".to_string(),
5179                        },
5180                    })),
5181                    Ok(StreamEvent::PartStop { index: Some(0) }),
5182                    Err(ApiError::RateLimit {
5183                        retry_after: None,
5184                        message: "slow down".into(),
5185                    }),
5186                ];
5187                Box::pin(futures::stream::iter(events))
5188            }
5189            fn create_message(
5190                &self,
5191                _request: &crate::api::StreamRequest,
5192            ) -> std::pin::Pin<
5193                Box<
5194                    dyn std::future::Future<
5195                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5196                        > + Send
5197                        + '_,
5198                >,
5199            > {
5200                Box::pin(async { Err(ApiError::api("unused")) })
5201            }
5202        }
5203
5204        let handler = StreamHandler::new()
5205            .with_rate_limit_config(RateLimitConfig {
5206                fallback_after_retries: 1,
5207                max_retries: 1,
5208                default_delay: Duration::from_millis(1),
5209                max_delay: Duration::from_millis(1),
5210                ..Default::default()
5211            })
5212            .with_timeout_config(StreamTimeoutConfig {
5213                fallback_to_non_streaming: false,
5214                ..Default::default()
5215            });
5216        let cancel = Arc::new(CancelSignal::new());
5217        let err = handler
5218            .drive_turn(
5219                &PartialThenRateLimitMock,
5220                &crate::api::StreamRequest::new(vec![]),
5221                &cancel,
5222            )
5223            .await
5224            .expect_err("the disabled fallback makes the hard stop terminal");
5225        match err {
5226            StreamHandlerError::StreamFailed(StreamOutcome::RateLimited {
5227                has_partial_data,
5228                events_processed,
5229                ..
5230            }) => {
5231                assert!(
5232                    has_partial_data,
5233                    "a 429 after accepted events must report salvageable partial data"
5234                );
5235                assert_eq!(
5236                    events_processed, 4,
5237                    "the outcome counts the events that got through before the 429"
5238                );
5239            }
5240            other => panic!("expected a RateLimited terminal, got {other:?}"),
5241        }
5242    }
5243
5244    #[tokio::test]
5245    async fn event_timeout_after_partial_data_reports_has_partial_data() {
5246        struct PartialThenHangMock;
5247        impl ApiClient for PartialThenHangMock {
5248            fn model(&self) -> String {
5249                "partial-then-hang".to_string()
5250            }
5251            fn stream_messages(
5252                &self,
5253                _request: &crate::api::StreamRequest,
5254            ) -> std::pin::Pin<
5255                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5256            > {
5257                let events = vec![
5258                    Ok(StreamEvent::MessageStart(MessageStart {
5259                        message: MessageMetadata {
5260                            id: "m1".to_string(),
5261                            role: "assistant".to_string(),
5262                            model: "partial-then-hang".to_string(),
5263                        },
5264                    })),
5265                    Ok(StreamEvent::PartStart(PartStart {
5266                        index: 0,
5267                        part: Some(crate::stream::MessagePart::text("")),
5268                    })),
5269                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
5270                        index: 0,
5271                        delta: DeltaPart::Text {
5272                            text: "partial".to_string(),
5273                        },
5274                    })),
5275                    Ok(StreamEvent::PartStop { index: Some(0) }),
5276                ];
5277                let pending = futures::stream::pending();
5278                Box::pin(futures::stream::iter(events).chain(pending))
5279            }
5280            fn create_message(
5281                &self,
5282                _request: &crate::api::StreamRequest,
5283            ) -> std::pin::Pin<
5284                Box<
5285                    dyn std::future::Future<
5286                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5287                        > + Send
5288                        + '_,
5289                >,
5290            > {
5291                Box::pin(async { Err(ApiError::api("unused")) })
5292            }
5293        }
5294
5295        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
5296            initial_event_timeout: Duration::from_millis(50),
5297            per_event_timeout: Duration::from_millis(50),
5298            max_consecutive_timeouts: 1,
5299            fallback_to_non_streaming: false,
5300            ..Default::default()
5301        });
5302        let cancel = Arc::new(CancelSignal::new());
5303        let err = handler
5304            .drive_turn(
5305                &PartialThenHangMock,
5306                &crate::api::StreamRequest::new(vec![]),
5307                &cancel,
5308            )
5309            .await
5310            .expect_err("the hang must terminate via the event timeout");
5311        match err {
5312            StreamHandlerError::StreamFailed(StreamOutcome::EventTimeout {
5313                has_partial_data,
5314                consecutive_timeouts,
5315            }) => {
5316                assert!(
5317                    has_partial_data,
5318                    "a hang after accepted events must report salvageable partial data"
5319                );
5320                assert_eq!(consecutive_timeouts, 1);
5321            }
5322            other => panic!("expected an EventTimeout terminal, got {other:?}"),
5323        }
5324    }
5325
5326    #[tokio::test]
5327    async fn retried_attempt_re_gates_on_the_rate_limiter() {
5328        struct FailOnceThenAnswerMock {
5329            calls: std::sync::atomic::AtomicUsize,
5330        }
5331        impl ApiClient for FailOnceThenAnswerMock {
5332            fn model(&self) -> String {
5333                "fail-once".to_string()
5334            }
5335            fn stream_messages(
5336                &self,
5337                _request: &crate::api::StreamRequest,
5338            ) -> std::pin::Pin<
5339                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5340            > {
5341                let calls = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5342                if calls == 0 {
5343                    return Box::pin(futures::stream::once(async {
5344                        Err(ApiError::http("transient transport failure"))
5345                    }));
5346                }
5347                let events = vec![
5348                    Ok(StreamEvent::MessageStart(MessageStart {
5349                        message: MessageMetadata {
5350                            id: "m1".to_string(),
5351                            role: "assistant".to_string(),
5352                            model: "fail-once".to_string(),
5353                        },
5354                    })),
5355                    Ok(StreamEvent::PartStart(PartStart {
5356                        index: 0,
5357                        part: Some(crate::stream::MessagePart::text("")),
5358                    })),
5359                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
5360                        index: 0,
5361                        delta: DeltaPart::Text {
5362                            text: "recovered".to_string(),
5363                        },
5364                    })),
5365                    Ok(StreamEvent::PartStop { index: Some(0) }),
5366                    Ok(StreamEvent::MessageStop),
5367                ];
5368                Box::pin(futures::stream::iter(events))
5369            }
5370            fn create_message(
5371                &self,
5372                _request: &crate::api::StreamRequest,
5373            ) -> std::pin::Pin<
5374                Box<
5375                    dyn std::future::Future<
5376                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5377                        > + Send
5378                        + '_,
5379                >,
5380            > {
5381                Box::pin(async { Err(ApiError::api("unused")) })
5382            }
5383        }
5384
5385        let handler = StreamHandler::new()
5386            .with_rate_limiter(Arc::new(crate::stream::rate_limit::RateLimiter::new(1)))
5387            .with_rate_limit_max_wait(Duration::from_millis(1200))
5388            .with_retry_config(crate::stream::handler::StreamRetryConfig {
5389                base_delay_ms: 1,
5390                max_delay_ms: 1,
5391                ..Default::default()
5392            });
5393        let cancel = Arc::new(CancelSignal::new());
5394        let client = FailOnceThenAnswerMock {
5395            calls: std::sync::atomic::AtomicUsize::new(0),
5396        };
5397        let started = std::time::Instant::now();
5398        handler
5399            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5400            .await
5401            .expect("the retried attempt must succeed");
5402        let elapsed = started.elapsed();
5403        assert!(
5404            elapsed >= Duration::from_millis(1000),
5405            "the retried attempt must re-gate on the limiter and wait out the max-wait \
5406             ceiling (1 rpm = the first attempt drains the bucket); elapsed {elapsed:?}"
5407        );
5408    }
5409
5410    #[tokio::test]
5411    async fn stream_turn_rate_limit_budget_independent_of_transport() {
5412        // Transport retry budget is tiny (max_retries = 1 -> 2 attempts), but
5413        // fallback_after_retries = 3. A leading transport error must NOT consume
5414        // the rate-limit budget: after the one transport failure, three
5415        // rate-limit retries must still be honored before escalating. Under the
5416        // old shared-counter loop this would exhaust the transport budget first
5417        // and fall through to non-streaming fallback instead of escalating.
5418        use std::sync::atomic::{AtomicUsize, Ordering};
5419
5420        struct TransportThenRateLimitMock {
5421            calls: AtomicUsize,
5422        }
5423        impl ApiClient for TransportThenRateLimitMock {
5424            fn model(&self) -> String {
5425                "test-model".to_string()
5426            }
5427            fn stream_messages(
5428                &self,
5429                _request: &crate::api::StreamRequest,
5430            ) -> std::pin::Pin<
5431                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5432            > {
5433                let n = self.calls.fetch_add(1, Ordering::SeqCst);
5434                let result = if n == 0 {
5435                    Err(ApiError::api("connection refused"))
5436                } else {
5437                    Err(ApiError::RateLimit {
5438                        retry_after: Some(Duration::from_millis(1)),
5439                        message: "slow down".into(),
5440                    })
5441                };
5442                Box::pin(futures::stream::once(async { result }))
5443            }
5444            fn create_message(
5445                &self,
5446                _request: &crate::api::StreamRequest,
5447            ) -> std::pin::Pin<
5448                Box<
5449                    dyn std::future::Future<
5450                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5451                        > + Send
5452                        + '_,
5453                >,
5454            > {
5455                Box::pin(async {
5456                    Ok(crate::api::NonStreamingResponse {
5457                        message: crate::message::Message::assistant(""),
5458                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5459                        usage: Some(crate::stream::Usage::default()),
5460                    })
5461                })
5462            }
5463        }
5464
5465        let handler = StreamHandler::new()
5466            .with_timeout_config(StreamTimeoutConfig {
5467                fallback_to_non_streaming: false,
5468                ..Default::default()
5469            })
5470            .with_retry_config(StreamRetryConfig {
5471                max_retries: 1,
5472                base_delay_ms: 1,
5473                ..Default::default()
5474            })
5475            .with_rate_limit_config(RateLimitConfig {
5476                fallback_after_retries: 3,
5477                default_delay: Duration::from_millis(1),
5478                max_delay: Duration::from_millis(1),
5479                ..Default::default()
5480            });
5481
5482        let client = TransportThenRateLimitMock {
5483            calls: AtomicUsize::new(0),
5484        };
5485        let cancel = Arc::new(CancelSignal::new());
5486
5487        let err = handler
5488            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5489            .await
5490            .expect_err("should escalate after the rate-limit budget, not fall through");
5491        match err {
5492            StreamHandlerError::RateLimitEscalation { attempts, .. } => {
5493                // One transport failure (not counted) + 3 rate-limit retries,
5494                // escalation on the 4th rate-limit hit.
5495                assert_eq!(attempts, 4);
5496            }
5497            other => panic!("expected RateLimitEscalation, got {other:?}"),
5498        }
5499    }
5500
5501    #[tokio::test]
5502    async fn stream_turn_rate_limit_hard_stop_after_max_retries() {
5503        // fallback_after_retries high so escalation never fires; max_retries low so
5504        // the hard-stop backstop returns the underlying rate-limit outcome.
5505        struct AlwaysRateLimitMock;
5506        impl ApiClient for AlwaysRateLimitMock {
5507            fn model(&self) -> String {
5508                "test-model".to_string()
5509            }
5510            fn stream_messages(
5511                &self,
5512                _request: &crate::api::StreamRequest,
5513            ) -> std::pin::Pin<
5514                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5515            > {
5516                Box::pin(futures::stream::once(async {
5517                    Err(ApiError::RateLimit {
5518                        retry_after: None,
5519                        message: "slow down".into(),
5520                    })
5521                }))
5522            }
5523            fn create_message(
5524                &self,
5525                _request: &crate::api::StreamRequest,
5526            ) -> std::pin::Pin<
5527                Box<
5528                    dyn std::future::Future<
5529                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5530                        > + Send
5531                        + '_,
5532                >,
5533            > {
5534                Box::pin(async {
5535                    Ok(crate::api::NonStreamingResponse {
5536                        message: crate::message::Message::assistant(""),
5537                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5538                        usage: Some(crate::stream::Usage::default()),
5539                    })
5540                })
5541            }
5542        }
5543
5544        let handler = StreamHandler::new()
5545            .with_timeout_config(StreamTimeoutConfig {
5546                fallback_to_non_streaming: false,
5547                ..Default::default()
5548            })
5549            .with_rate_limit_config(RateLimitConfig {
5550                fallback_after_retries: 2,
5551                max_retries: 2,
5552                default_delay: Duration::from_millis(1),
5553                max_delay: Duration::from_millis(1),
5554                ..Default::default()
5555            });
5556        let client = AlwaysRateLimitMock;
5557        let cancel = Arc::new(CancelSignal::new());
5558
5559        let err = handler
5560            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5561            .await
5562            .expect_err("hard-stop should fail the turn");
5563        match err {
5564            StreamHandlerError::StreamFailed(StreamOutcome::RateLimited { .. })
5565            | StreamHandlerError::InitFailed(StreamOutcome::RateLimited { .. }) => {}
5566            StreamHandlerError::RateLimitEscalation { .. } => {
5567                panic!("escalation must not fire when max_retries == fallback_after_retries")
5568            }
5569            other => panic!("expected rate-limit outcome, got {other:?}"),
5570        }
5571    }
5572
5573    #[tokio::test]
5574    async fn stream_turn_rate_limit_counter_does_not_leak_across_calls() {
5575        // The rate_limit_retries counter is a per-call local, so two independent
5576        // stream_turn calls on the same handler must each start fresh.
5577        use std::sync::atomic::{AtomicUsize, Ordering};
5578
5579        struct RateLimitOnceMock {
5580            attempts: AtomicUsize,
5581        }
5582        impl ApiClient for RateLimitOnceMock {
5583            fn model(&self) -> String {
5584                "test-model".to_string()
5585            }
5586            fn stream_messages(
5587                &self,
5588                _request: &crate::api::StreamRequest,
5589            ) -> std::pin::Pin<
5590                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5591            > {
5592                let n = self.attempts.fetch_add(1, Ordering::SeqCst);
5593                if n == 0 {
5594                    Box::pin(futures::stream::once(async {
5595                        Err(ApiError::RateLimit {
5596                            retry_after: None,
5597                            message: "slow down".into(),
5598                        })
5599                    }))
5600                } else {
5601                    Box::pin(futures::stream::iter(happy_stream_events()))
5602                }
5603            }
5604            fn create_message(
5605                &self,
5606                _request: &crate::api::StreamRequest,
5607            ) -> std::pin::Pin<
5608                Box<
5609                    dyn std::future::Future<
5610                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5611                        > + Send
5612                        + '_,
5613                >,
5614            > {
5615                Box::pin(async {
5616                    Ok(crate::api::NonStreamingResponse {
5617                        message: crate::message::Message::assistant(""),
5618                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5619                        usage: Some(crate::stream::Usage::default()),
5620                    })
5621                })
5622            }
5623        }
5624
5625        let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig {
5626            default_delay: Duration::from_millis(1),
5627            max_delay: Duration::from_millis(1),
5628            ..Default::default()
5629        });
5630
5631        // First call: one rate-limit, then success.
5632        let client = RateLimitOnceMock {
5633            attempts: AtomicUsize::new(0),
5634        };
5635        let cancel = Arc::new(CancelSignal::new());
5636        handler
5637            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5638            .await
5639            .expect("first call should succeed after one rate-limit retry");
5640
5641        // Second call on the SAME handler: fresh counter, so it must succeed too
5642        // rather than escalating on a leaked count.
5643        let client2 = RateLimitOnceMock {
5644            attempts: AtomicUsize::new(0),
5645        };
5646        handler
5647            .drive_turn(&client2, &crate::api::StreamRequest::new(vec![]), &cancel)
5648            .await
5649            .expect("second call should not see leaked rate-limit state");
5650    }
5651
5652    #[tokio::test]
5653    async fn stream_turn_non_rate_limit_error_path_unchanged() {
5654        // Regression guard: a plain transport error still follows the generic
5655        // exponential-backoff path and returns StreamFailed, never escalation.
5656        struct AlwaysFailingMock;
5657        impl ApiClient for AlwaysFailingMock {
5658            fn model(&self) -> String {
5659                "test-model".to_string()
5660            }
5661            fn stream_messages(
5662                &self,
5663                _request: &crate::api::StreamRequest,
5664            ) -> std::pin::Pin<
5665                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5666            > {
5667                Box::pin(futures::stream::once(async {
5668                    Err(ApiError::api("connection refused"))
5669                }))
5670            }
5671            fn create_message(
5672                &self,
5673                _request: &crate::api::StreamRequest,
5674            ) -> std::pin::Pin<
5675                Box<
5676                    dyn std::future::Future<
5677                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5678                        > + Send
5679                        + '_,
5680                >,
5681            > {
5682                Box::pin(async {
5683                    Ok(crate::api::NonStreamingResponse {
5684                        message: crate::message::Message::assistant(""),
5685                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5686                        usage: Some(crate::stream::Usage::default()),
5687                    })
5688                })
5689            }
5690        }
5691
5692        let handler = StreamHandler::new()
5693            .with_timeout_config(StreamTimeoutConfig {
5694                fallback_to_non_streaming: false,
5695                ..Default::default()
5696            })
5697            .with_retry_config(StreamRetryConfig {
5698                max_retries: 1,
5699                base_delay_ms: 1,
5700                ..Default::default()
5701            });
5702        let client = AlwaysFailingMock;
5703        let cancel = Arc::new(CancelSignal::new());
5704
5705        let err = handler
5706            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5707            .await
5708            .expect_err("transport errors should fail");
5709        assert!(
5710            !matches!(err, StreamHandlerError::RateLimitEscalation { .. }),
5711            "non-rate-limit errors must not escalate"
5712        );
5713    }
5714
5715    #[tokio::test]
5716    async fn stream_turn_rate_limit_delay_clamped_to_total_timeout() {
5717        // A huge Retry-After against a tight total_stream_timeout must fail
5718        // promptly (TotalTimeout), not sleep for the full hint.
5719        struct AlwaysRateLimitMock;
5720        impl ApiClient for AlwaysRateLimitMock {
5721            fn model(&self) -> String {
5722                "test-model".to_string()
5723            }
5724            fn stream_messages(
5725                &self,
5726                _request: &crate::api::StreamRequest,
5727            ) -> std::pin::Pin<
5728                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5729            > {
5730                Box::pin(futures::stream::once(async {
5731                    Err(ApiError::RateLimit {
5732                        retry_after: Some(Duration::from_mins(10)),
5733                        message: "slow down".into(),
5734                    })
5735                }))
5736            }
5737            fn create_message(
5738                &self,
5739                _request: &crate::api::StreamRequest,
5740            ) -> std::pin::Pin<
5741                Box<
5742                    dyn std::future::Future<
5743                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5744                        > + Send
5745                        + '_,
5746                >,
5747            > {
5748                Box::pin(async {
5749                    Ok(crate::api::NonStreamingResponse {
5750                        message: crate::message::Message::assistant(""),
5751                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5752                        usage: Some(crate::stream::Usage::default()),
5753                    })
5754                })
5755            }
5756        }
5757
5758        let handler = StreamHandler::new()
5759            .with_timeout_config(StreamTimeoutConfig {
5760                initial_event_timeout: Duration::from_millis(40),
5761                per_event_timeout: Duration::from_millis(40),
5762                total_stream_timeout: Duration::from_millis(80),
5763                ..Default::default()
5764            })
5765            .with_retry_config(StreamRetryConfig {
5766                max_retries: 10,
5767                base_delay_ms: 1,
5768                ..Default::default()
5769            })
5770            .with_rate_limit_config(RateLimitConfig {
5771                // Honour the hint, but max_delay lets the 600s through so the
5772                // deadline clamp is what must bound the sleep.
5773                max_delay: Duration::from_mins(10),
5774                default_delay: Duration::from_millis(1),
5775                fallback_after_retries: 100,
5776                max_retries: 100,
5777                ..Default::default()
5778            });
5779        let client = AlwaysRateLimitMock;
5780        let cancel = Arc::new(CancelSignal::new());
5781
5782        let start = Instant::now();
5783        let result = handler
5784            .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
5785            .await;
5786        let elapsed = start.elapsed();
5787        // The clamp keeps the sleep inside the ~80ms budget, so the whole turn
5788        // resolves well under the 600s hint. Allow generous slack for scheduling.
5789        assert!(
5790            elapsed < Duration::from_secs(2),
5791            "deadline clamp should prevent a 600s sleep; elapsed {elapsed:?}",
5792        );
5793        // The expiry is terminal: with the fallback enabled by default the
5794        // turn resolves through it, otherwise it fails — never the 600s hint,
5795        // never escalation (the deadline trips before the counter ceiling).
5796        match result {
5797            Ok(done) => assert!(
5798                done.from_fallback,
5799                "a prompt success here can only be the non-streaming fallback"
5800            ),
5801            Err(err) => assert!(
5802                !matches!(err, StreamHandlerError::RateLimitEscalation { .. }),
5803                "timeout should fire before escalation"
5804            ),
5805        }
5806    }
5807
5808    #[tokio::test]
5809    async fn stream_turn_cancel_during_backoff_returns_immediately() {
5810        struct AlwaysFailingMock;
5811        impl ApiClient for AlwaysFailingMock {
5812            fn model(&self) -> String {
5813                "test-model".to_string()
5814            }
5815            fn stream_messages(
5816                &self,
5817                _request: &crate::api::StreamRequest,
5818            ) -> std::pin::Pin<
5819                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5820            > {
5821                Box::pin(futures::stream::once(async {
5822                    Err(ApiError::api("connection lost"))
5823                }))
5824            }
5825            fn create_message(
5826                &self,
5827                _request: &crate::api::StreamRequest,
5828            ) -> std::pin::Pin<
5829                Box<
5830                    dyn std::future::Future<
5831                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5832                        > + Send
5833                        + '_,
5834                >,
5835            > {
5836                Box::pin(async {
5837                    Ok(crate::api::NonStreamingResponse {
5838                        message: crate::message::Message::assistant(""),
5839                        stop_reason: crate::stream::StreamStopReason::EndTurn,
5840                        usage: Some(crate::stream::Usage::default()),
5841                    })
5842                })
5843            }
5844        }
5845
5846        let handler = StreamHandler::new().with_retry_config(StreamRetryConfig {
5847            max_retries: 5,
5848            base_delay_ms: 60_000,
5849            ..Default::default()
5850        });
5851        let cancel = Arc::new(CancelSignal::new());
5852        let cancel_clone = Arc::clone(&cancel);
5853        tokio::spawn(async move {
5854            tokio::task::yield_now().await;
5855            cancel_clone.cancel();
5856        });
5857
5858        let start = Instant::now();
5859        let err = handler
5860            .drive_turn(
5861                &AlwaysFailingMock,
5862                &crate::api::StreamRequest::new(vec![]),
5863                &cancel,
5864            )
5865            .await
5866            .expect_err("should return Cancelled, not hang for 60s");
5867        let elapsed = start.elapsed();
5868
5869        assert!(
5870            matches!(err, StreamHandlerError::Cancelled),
5871            "expected Cancelled, got {err:?}",
5872        );
5873        assert!(
5874            elapsed < Duration::from_secs(5),
5875            "cancellation during backoff should return immediately, not wait for the 60s sleep; elapsed {elapsed:?}",
5876        );
5877    }
5878
5879    #[tokio::test]
5880    async fn malformed_event_fails_the_stream_with_attempt_context() {
5881        struct GarbageToolInputMock;
5882        impl ApiClient for GarbageToolInputMock {
5883            fn model(&self) -> String {
5884                "garbage-input".to_string()
5885            }
5886            fn stream_messages(
5887                &self,
5888                _request: &crate::api::StreamRequest,
5889            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
5890            {
5891                let events = vec![
5892                    Ok(StreamEvent::MessageStart(MessageStart {
5893                        message: MessageMetadata {
5894                            id: "m1".to_string(),
5895                            role: "assistant".to_string(),
5896                            model: "garbage-input".to_string(),
5897                        },
5898                    })),
5899                    Ok(StreamEvent::PartStart(PartStart {
5900                        index: 0,
5901                        part: Some(crate::stream::MessagePart::tool_call(
5902                            "t1",
5903                            "search",
5904                            serde_json::json!({}),
5905                        )),
5906                    })),
5907                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
5908                        index: 0,
5909                        delta: DeltaPart::InputJson {
5910                            partial_json: "not json".to_string(),
5911                        },
5912                    })),
5913                    Ok(StreamEvent::PartStop { index: None }),
5914                ];
5915                Box::pin(futures::stream::iter(events))
5916            }
5917            fn create_message(
5918                &self,
5919                _request: &crate::api::StreamRequest,
5920            ) -> Pin<
5921                Box<
5922                    dyn std::future::Future<
5923                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
5924                        > + Send
5925                        + '_,
5926                >,
5927            > {
5928                Box::pin(async { Err(ApiError::http("unused")) })
5929            }
5930        }
5931
5932        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
5933            initial_event_timeout: Duration::from_secs(5),
5934            per_event_timeout: Duration::from_secs(5),
5935            total_stream_timeout: Duration::from_secs(60),
5936            max_consecutive_timeouts: 3,
5937            fallback_to_non_streaming: false,
5938        });
5939        let cancel = Arc::new(CancelSignal::new());
5940        let request = crate::api::StreamRequest::new(vec![]);
5941        let mut stream = handler.stream_turn(
5942            &GarbageToolInputMock,
5943            &request,
5944            crate::structured::RequestOptions::default(),
5945            &cancel,
5946        );
5947        let mut yielded = 0usize;
5948        let mut terminal = None;
5949        while let Some(item) = stream.next().await {
5950            match item {
5951                Ok(HandlerEvent::Stream(_)) => yielded += 1,
5952                Ok(_) => {}
5953                Err(e) => {
5954                    terminal = Some(e);
5955                    break;
5956                }
5957            }
5958        }
5959        assert_eq!(
5960            yielded, 12,
5961            "each ladder attempt replays the accepted events before the malformed one (4 attempts × 3)"
5962        );
5963        match terminal.expect("the malformed event must fail the stream") {
5964            StreamHandlerError::StreamFailed(StreamOutcome::InitFailed {
5965                attempts,
5966                last_error,
5967            }) => {
5968                assert_eq!(
5969                    attempts, 4,
5970                    "the failure counts every attempt the ladder made"
5971                );
5972                assert!(
5973                    last_error.contains("invalid tool input JSON"),
5974                    "the accumulator's rejection surfaces verbatim, got: {last_error}"
5975                );
5976            }
5977            other => panic!("expected a StreamFailed InitFailed terminal, got {other:?}"),
5978        }
5979    }
5980
5981    #[tokio::test]
5982    async fn truncated_stream_is_not_a_completed_turn() {
5983        struct CutStreamMock;
5984        impl ApiClient for CutStreamMock {
5985            fn model(&self) -> String {
5986                "cut-stream".to_string()
5987            }
5988            fn stream_messages(
5989                &self,
5990                _request: &crate::api::StreamRequest,
5991            ) -> std::pin::Pin<
5992                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
5993            > {
5994                let events = vec![
5995                    Ok(StreamEvent::MessageStart(MessageStart {
5996                        message: MessageMetadata {
5997                            id: "m1".to_string(),
5998                            role: "assistant".to_string(),
5999                            model: "cut-stream".to_string(),
6000                        },
6001                    })),
6002                    Ok(StreamEvent::PartStart(PartStart {
6003                        index: 0,
6004                        part: Some(crate::stream::MessagePart::text("")),
6005                    })),
6006                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
6007                        index: 0,
6008                        delta: DeltaPart::Text {
6009                            text: "partial".to_string(),
6010                        },
6011                    })),
6012                ];
6013                Box::pin(futures::stream::iter(events))
6014            }
6015            fn create_message(
6016                &self,
6017                _request: &crate::api::StreamRequest,
6018            ) -> std::pin::Pin<
6019                Box<
6020                    dyn std::future::Future<
6021                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6022                        > + Send
6023                        + '_,
6024                >,
6025            > {
6026                Box::pin(async { Err(ApiError::api("unused")) })
6027            }
6028        }
6029
6030        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
6031            fallback_to_non_streaming: false,
6032            ..Default::default()
6033        });
6034        let cancel = Arc::new(CancelSignal::new());
6035        let err = handler
6036            .drive_turn(
6037                &CutStreamMock,
6038                &crate::api::StreamRequest::new(vec![]),
6039                &cancel,
6040            )
6041            .await
6042            .expect_err("a stream that ends without a terminal event is truncated");
6043        let rendered = err.to_string();
6044        assert!(
6045            rendered.contains("without a terminal event") && !rendered.contains("init failed"),
6046            "the engine-facing message must name the truncation, not the \
6047             historical init framing: {rendered}"
6048        );
6049    }
6050
6051    #[tokio::test]
6052    async fn truncated_stream_with_fallback_enabled_gets_the_ladder() {
6053        struct CutStreamThenAnswerMock;
6054        impl ApiClient for CutStreamThenAnswerMock {
6055            fn model(&self) -> String {
6056                "cut-then-answer".to_string()
6057            }
6058            fn stream_messages(
6059                &self,
6060                _request: &crate::api::StreamRequest,
6061            ) -> std::pin::Pin<
6062                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
6063            > {
6064                let events = vec![
6065                    Ok(StreamEvent::MessageStart(MessageStart {
6066                        message: MessageMetadata {
6067                            id: "m1".to_string(),
6068                            role: "assistant".to_string(),
6069                            model: "cut-then-answer".to_string(),
6070                        },
6071                    })),
6072                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
6073                        index: 0,
6074                        delta: DeltaPart::Text {
6075                            text: "partial".to_string(),
6076                        },
6077                    })),
6078                ];
6079                Box::pin(futures::stream::iter(events))
6080            }
6081            fn create_message(
6082                &self,
6083                _request: &crate::api::StreamRequest,
6084            ) -> std::pin::Pin<
6085                Box<
6086                    dyn std::future::Future<
6087                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6088                        > + Send
6089                        + '_,
6090                >,
6091            > {
6092                Box::pin(async {
6093                    Ok(crate::api::NonStreamingResponse {
6094                        message: crate::message::Message::assistant("fallback ok"),
6095                        stop_reason: crate::stream::StreamStopReason::EndTurn,
6096                        usage: Some(crate::stream::Usage::default()),
6097                    })
6098                })
6099            }
6100        }
6101
6102        let handler = StreamHandler::new();
6103        let cancel = Arc::new(CancelSignal::new());
6104        let request = crate::api::StreamRequest::new(vec![]);
6105        let mut stream = handler.stream_turn(
6106            &CutStreamThenAnswerMock,
6107            &request,
6108            crate::structured::RequestOptions::default(),
6109            &cancel,
6110        );
6111        let mut fallback_message = None;
6112        while let Some(item) = stream.next().await {
6113            match item {
6114                Ok(HandlerEvent::Fallback { message, .. }) => fallback_message = Some(message),
6115                Err(e) => panic!(
6116                    "a truncated stream with the fallback enabled must not fail the turn: {e}"
6117                ),
6118                _ => {}
6119            }
6120        }
6121        assert_eq!(
6122            fallback_message
6123                .expect("the non-streaming fallback must serve the truncated turn")
6124                .text_content(),
6125            "fallback ok"
6126        );
6127    }
6128
6129    #[tokio::test]
6130    async fn malformed_event_with_fallback_enabled_gets_the_ladder() {
6131        struct GarbageThenAnswerMock;
6132        impl ApiClient for GarbageThenAnswerMock {
6133            fn model(&self) -> String {
6134                "garbage-then-answer".to_string()
6135            }
6136            fn stream_messages(
6137                &self,
6138                _request: &crate::api::StreamRequest,
6139            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6140            {
6141                let events = vec![
6142                    Ok(StreamEvent::MessageStart(MessageStart {
6143                        message: MessageMetadata {
6144                            id: "m1".to_string(),
6145                            role: "assistant".to_string(),
6146                            model: "garbage-then-answer".to_string(),
6147                        },
6148                    })),
6149                    Ok(StreamEvent::PartStart(PartStart {
6150                        index: 0,
6151                        part: Some(crate::stream::MessagePart::tool_call(
6152                            "t1",
6153                            "search",
6154                            serde_json::json!({}),
6155                        )),
6156                    })),
6157                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
6158                        index: 0,
6159                        delta: DeltaPart::InputJson {
6160                            partial_json: "not json".to_string(),
6161                        },
6162                    })),
6163                    Ok(StreamEvent::PartStop { index: Some(0) }),
6164                ];
6165                Box::pin(futures::stream::iter(events))
6166            }
6167            fn create_message(
6168                &self,
6169                _request: &crate::api::StreamRequest,
6170            ) -> Pin<
6171                Box<
6172                    dyn std::future::Future<
6173                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6174                        > + Send
6175                        + '_,
6176                >,
6177            > {
6178                Box::pin(async {
6179                    Ok(crate::api::NonStreamingResponse {
6180                        message: crate::message::Message::assistant("fallback ok"),
6181                        stop_reason: crate::stream::StreamStopReason::EndTurn,
6182                        usage: Some(crate::stream::Usage::default()),
6183                    })
6184                })
6185            }
6186        }
6187
6188        let handler = StreamHandler::new();
6189        let cancel = Arc::new(CancelSignal::new());
6190        let request = crate::api::StreamRequest::new(vec![]);
6191        let mut stream = handler.stream_turn(
6192            &GarbageThenAnswerMock,
6193            &request,
6194            crate::structured::RequestOptions::default(),
6195            &cancel,
6196        );
6197        let mut fallback_message = None;
6198        while let Some(item) = stream.next().await {
6199            match item {
6200                Ok(HandlerEvent::Fallback { message, .. }) => fallback_message = Some(message),
6201                Err(e) => panic!(
6202                    "a malformed event with the fallback enabled must not fail the turn: {e}"
6203                ),
6204                _ => {}
6205            }
6206        }
6207        assert_eq!(
6208            fallback_message
6209                .expect("the non-streaming fallback must serve the turn")
6210                .text_content(),
6211            "fallback ok",
6212            "exhausting the retry ladder on accumulation failures routes to the fallback"
6213        );
6214    }
6215
6216    #[test]
6217    fn http_429_is_classified_as_rate_limited() {
6218        let detected =
6219            DetectedRateLimit::detect(&ApiError::http_with_status(429, "Too Many Requests"))
6220                .expect("429 must be detected as a rate limit");
6221        assert_eq!(
6222            detected.kind,
6223            RateLimitKind::RateLimited,
6224            "doc: RateLimited is the HTTP 429 Too Many Requests kind"
6225        );
6226    }
6227
6228    #[test]
6229    fn rate_limit_variant_kind_splits_by_message_status() {
6230        let overload =
6231            DetectedRateLimit::detect(&ApiError::rate_limited("HTTP 503: unavailable", None))
6232                .expect("a 503-shaped RateLimit must be detected");
6233        assert!(
6234            matches!(overload.kind, RateLimitKind::Overloaded),
6235            "503 is the Overloaded kind, got {:?}",
6236            overload.kind
6237        );
6238        let overloaded_529 =
6239            DetectedRateLimit::detect(&ApiError::rate_limited("HTTP 529: overloaded", None))
6240                .expect("a 529-shaped RateLimit must be detected");
6241        assert!(matches!(overloaded_529.kind, RateLimitKind::Overloaded));
6242        let quota = DetectedRateLimit::detect(&ApiError::rate_limited("HTTP 429: slow down", None))
6243            .expect("a 429-shaped RateLimit must be detected");
6244        assert!(matches!(quota.kind, RateLimitKind::RateLimited));
6245        let untyped =
6246            DetectedRateLimit::detect(&ApiError::rate_limited("provider quota text", None))
6247                .expect("a statusless RateLimit must be detected");
6248        assert!(
6249            matches!(untyped.kind, RateLimitKind::RateLimited),
6250            "without an embedded status the default kind is RateLimited"
6251        );
6252    }
6253
6254    /// A stream client whose every attempt fails with the given error.
6255    ///
6256    /// Builds the error per call from a factory (the error type is not
6257    /// `Clone`) and counts `stream_messages` / `create_message` calls so the
6258    /// permanent-error contracts can assert exactly how many attempts the
6259    /// handler spent before giving up.
6260    struct FailingStreamClient {
6261        make_error: fn() -> ApiError,
6262        stream_calls: std::sync::atomic::AtomicUsize,
6263        non_streaming_calls: std::sync::atomic::AtomicUsize,
6264    }
6265
6266    impl FailingStreamClient {
6267        fn failing_with(make_error: fn() -> ApiError) -> Self {
6268            Self {
6269                make_error,
6270                stream_calls: std::sync::atomic::AtomicUsize::new(0),
6271                non_streaming_calls: std::sync::atomic::AtomicUsize::new(0),
6272            }
6273        }
6274
6275        fn stream_calls(&self) -> usize {
6276            self.stream_calls.load(std::sync::atomic::Ordering::SeqCst)
6277        }
6278
6279        fn non_streaming_calls(&self) -> usize {
6280            self.non_streaming_calls
6281                .load(std::sync::atomic::Ordering::SeqCst)
6282        }
6283    }
6284
6285    impl ApiClient for FailingStreamClient {
6286        fn model(&self) -> String {
6287            "failing".to_string()
6288        }
6289
6290        fn stream_messages(
6291            &self,
6292            _request: &crate::api::StreamRequest,
6293        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
6294            self.stream_calls
6295                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6296            Box::pin(futures::stream::iter(vec![Err((self.make_error)())]))
6297        }
6298
6299        fn create_message(
6300            &self,
6301            _request: &crate::api::StreamRequest,
6302        ) -> Pin<
6303            Box<
6304                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
6305                    + Send
6306                    + '_,
6307            >,
6308        > {
6309            self.non_streaming_calls
6310                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6311            Box::pin(async { Err((self.make_error)()) })
6312        }
6313    }
6314
6315    /// Consume `stream_turn` to its terminal item, returning the error.
6316    async fn terminal_error<C: ApiClient>(
6317        handler: &StreamHandler,
6318        client: &C,
6319        cancel: &Arc<CancelSignal>,
6320    ) -> StreamHandlerError {
6321        let request = crate::api::StreamRequest::new(vec![]);
6322        let mut stream = handler.stream_turn(
6323            client,
6324            &request,
6325            crate::structured::RequestOptions::default(),
6326            cancel,
6327        );
6328        while let Some(item) = stream.next().await {
6329            if let Err(e) = item {
6330                return e;
6331            }
6332        }
6333        panic!("the stream must terminate with an error");
6334    }
6335
6336    #[tokio::test]
6337    async fn unauthorized_stream_errors_are_not_retried() {
6338        let client = FailingStreamClient::failing_with(|| {
6339            ApiError::auth_invalid_key("HTTP 401: invalid api key")
6340        });
6341        let handler = StreamHandler::new()
6342            .with_retry_config(StreamRetryConfig {
6343                max_retries: 3,
6344                base_delay_ms: 1,
6345                max_delay_ms: 2,
6346                ..Default::default()
6347            })
6348            .with_timeout_config(StreamTimeoutConfig {
6349                initial_event_timeout: Duration::from_secs(5),
6350                per_event_timeout: Duration::from_secs(5),
6351                total_stream_timeout: Duration::from_secs(60),
6352                max_consecutive_timeouts: 3,
6353                fallback_to_non_streaming: true,
6354            });
6355        let cancel = Arc::new(CancelSignal::new());
6356        let err = terminal_error(&handler, &client, &cancel).await;
6357        assert_eq!(
6358            client.stream_calls(),
6359            1,
6360            "a permanent 401 must cost exactly one streaming attempt"
6361        );
6362        assert_eq!(
6363            client.non_streaming_calls(),
6364            0,
6365            "a permanent 401 must not get a non-streaming fallback attempt"
6366        );
6367        match err {
6368            StreamHandlerError::StreamFailed(StreamOutcome::InitFailed { last_error, .. }) => {
6369                assert!(
6370                    last_error.contains("Invalid API key"),
6371                    "the auth failure must surface verbatim, got: {last_error}"
6372                );
6373            }
6374            other => panic!("the 401 must fail the stream, got {other:?}"),
6375        }
6376    }
6377
6378    #[tokio::test]
6379    async fn internal_server_error_is_still_retried() {
6380        let client = FailingStreamClient::failing_with(|| ApiError::http_with_status(500, "boom"));
6381        let handler = StreamHandler::new()
6382            .with_retry_config(StreamRetryConfig {
6383                max_retries: 3,
6384                base_delay_ms: 1,
6385                max_delay_ms: 2,
6386                ..Default::default()
6387            })
6388            .with_timeout_config(StreamTimeoutConfig {
6389                initial_event_timeout: Duration::from_secs(5),
6390                per_event_timeout: Duration::from_secs(5),
6391                total_stream_timeout: Duration::from_secs(60),
6392                max_consecutive_timeouts: 3,
6393                fallback_to_non_streaming: false,
6394            });
6395        let cancel = Arc::new(CancelSignal::new());
6396        let err = terminal_error(&handler, &client, &cancel).await;
6397        assert_eq!(
6398            client.stream_calls(),
6399            4,
6400            "a 500-class error keeps the full ladder: initial + max_retries retries"
6401        );
6402        assert!(
6403            matches!(
6404                err,
6405                StreamHandlerError::StreamFailed(StreamOutcome::InitFailed { .. })
6406            ),
6407            "with the fallback disabled the exhausted ladder fails the stream, got {err:?}"
6408        );
6409    }
6410
6411    #[tokio::test]
6412    async fn not_found_stream_errors_are_not_retried() {
6413        let client =
6414            FailingStreamClient::failing_with(|| ApiError::http_with_status(404, "unknown model"));
6415        let handler = StreamHandler::new()
6416            .with_retry_config(StreamRetryConfig {
6417                max_retries: 3,
6418                base_delay_ms: 1,
6419                max_delay_ms: 2,
6420                ..Default::default()
6421            })
6422            .with_timeout_config(StreamTimeoutConfig {
6423                initial_event_timeout: Duration::from_secs(5),
6424                per_event_timeout: Duration::from_secs(5),
6425                total_stream_timeout: Duration::from_secs(60),
6426                max_consecutive_timeouts: 3,
6427                fallback_to_non_streaming: true,
6428            });
6429        let cancel = Arc::new(CancelSignal::new());
6430        let err = terminal_error(&handler, &client, &cancel).await;
6431        assert_eq!(
6432            client.stream_calls(),
6433            1,
6434            "a permanent 404 must cost exactly one streaming attempt"
6435        );
6436        assert_eq!(
6437            client.non_streaming_calls(),
6438            0,
6439            "a permanent 404 must not get a non-streaming fallback attempt"
6440        );
6441        match err {
6442            StreamHandlerError::StreamFailed(StreamOutcome::InitFailed { last_error, .. }) => {
6443                assert!(
6444                    last_error.contains("HTTP 404"),
6445                    "the permanent status must surface verbatim, got: {last_error}"
6446                );
6447            }
6448            other => panic!("the 404 must fail the stream, got {other:?}"),
6449        }
6450    }
6451
6452    /// A stream client that accepts the request and then never produces an
6453    /// event, counting calls so the cancellation contract can assert the
6454    /// handler never re-entered the retry ladder.
6455    struct StalledStreamClient {
6456        stream_calls: std::sync::atomic::AtomicUsize,
6457    }
6458
6459    impl ApiClient for StalledStreamClient {
6460        fn model(&self) -> String {
6461            "stalled".to_string()
6462        }
6463
6464        fn stream_messages(
6465            &self,
6466            _request: &crate::api::StreamRequest,
6467        ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
6468            self.stream_calls
6469                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6470            Box::pin(futures::stream::pending())
6471        }
6472
6473        fn create_message(
6474            &self,
6475            _request: &crate::api::StreamRequest,
6476        ) -> Pin<
6477            Box<
6478                dyn std::future::Future<Output = Result<crate::api::NonStreamingResponse, ApiError>>
6479                    + Send
6480                    + '_,
6481            >,
6482        > {
6483            Box::pin(async { Err(ApiError::http("no non-streaming path")) })
6484        }
6485    }
6486
6487    #[tokio::test]
6488    async fn mid_stream_total_timeout_takes_the_fallback_path() {
6489        struct StallThenFallbackMock {
6490            stream_calls: std::sync::atomic::AtomicUsize,
6491            non_streaming_calls: std::sync::atomic::AtomicUsize,
6492        }
6493        impl StallThenFallbackMock {
6494            fn counting() -> Self {
6495                Self {
6496                    stream_calls: std::sync::atomic::AtomicUsize::new(0),
6497                    non_streaming_calls: std::sync::atomic::AtomicUsize::new(0),
6498                }
6499            }
6500        }
6501        impl ApiClient for StallThenFallbackMock {
6502            fn model(&self) -> String {
6503                "stall-fallback".to_string()
6504            }
6505            fn stream_messages(
6506                &self,
6507                _request: &crate::api::StreamRequest,
6508            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6509            {
6510                self.stream_calls
6511                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6512                let events = vec![
6513                    Ok(StreamEvent::MessageStart(MessageStart {
6514                        message: MessageMetadata {
6515                            id: "m1".to_string(),
6516                            role: "assistant".to_string(),
6517                            model: "stall-fallback".to_string(),
6518                        },
6519                    })),
6520                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
6521                        index: 0,
6522                        delta: DeltaPart::Text {
6523                            text: "partial".to_string(),
6524                        },
6525                    })),
6526                ];
6527                Box::pin(futures::stream::iter(events).chain(futures::stream::pending()))
6528            }
6529            fn create_message(
6530                &self,
6531                _request: &crate::api::StreamRequest,
6532            ) -> Pin<
6533                Box<
6534                    dyn std::future::Future<
6535                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6536                        > + Send
6537                        + '_,
6538                >,
6539            > {
6540                self.non_streaming_calls
6541                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6542                Box::pin(async {
6543                    // A real fallback request is pending on its first poll,
6544                    // unlike an instantly-ready future — the sleep makes the
6545                    // test discriminate a fallback killed by the already
6546                    // expired streaming deadline.
6547                    tokio::time::sleep(Duration::from_millis(50)).await;
6548                    Ok(crate::api::NonStreamingResponse {
6549                        message: Message::new(
6550                            crate::message::Role::Assistant,
6551                            vec![crate::stream::MessagePart::text("fallback answer")],
6552                        ),
6553                        stop_reason: StreamStopReason::EndTurn,
6554                        usage: None,
6555                    })
6556                })
6557            }
6558        }
6559
6560        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
6561            initial_event_timeout: Duration::from_millis(200),
6562            per_event_timeout: Duration::from_millis(200),
6563            total_stream_timeout: Duration::from_millis(400),
6564            max_consecutive_timeouts: 10,
6565            fallback_to_non_streaming: true,
6566        });
6567        let cancel = Arc::new(CancelSignal::new());
6568        let request = crate::api::StreamRequest::new(vec![]);
6569        let client = StallThenFallbackMock::counting();
6570        let started = Instant::now();
6571        let mut stream = handler.stream_turn(
6572            &client,
6573            &request,
6574            crate::structured::RequestOptions::default(),
6575            &cancel,
6576        );
6577        let mut fell_back = false;
6578        while let Some(item) = stream.next().await {
6579            match item.expect("an expired deadline with fallback configured must not error") {
6580                HandlerEvent::Fallback { .. } => fell_back = true,
6581                HandlerEvent::Stream(_) | HandlerEvent::AttemptReset => {}
6582            }
6583        }
6584        assert!(
6585            fell_back,
6586            "a mid-stream total timeout must reach the non-streaming fallback, not a retry or a bare failure"
6587        );
6588        assert!(
6589            started.elapsed() >= Duration::from_millis(400),
6590            "the fallback must complete after the streaming deadline expired, at {started:?}+{elapsed:?}",
6591            elapsed = started.elapsed()
6592        );
6593        assert_eq!(
6594            client
6595                .stream_calls
6596                .load(std::sync::atomic::Ordering::SeqCst),
6597            1,
6598            "the expired deadline must cost exactly one streaming attempt"
6599        );
6600        assert_eq!(
6601            client
6602                .non_streaming_calls
6603                .load(std::sync::atomic::Ordering::SeqCst),
6604            1,
6605            "the fallback must run exactly once"
6606        );
6607    }
6608
6609    #[tokio::test]
6610    async fn mid_stream_total_timeout_is_not_retried() {
6611        struct CountingStallMock {
6612            stream_calls: std::sync::atomic::AtomicUsize,
6613        }
6614        impl ApiClient for CountingStallMock {
6615            fn model(&self) -> String {
6616                "counting-stall".to_string()
6617            }
6618            fn stream_messages(
6619                &self,
6620                _request: &crate::api::StreamRequest,
6621            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6622            {
6623                self.stream_calls
6624                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6625                let events = vec![
6626                    Ok(StreamEvent::MessageStart(MessageStart {
6627                        message: MessageMetadata {
6628                            id: "m1".to_string(),
6629                            role: "assistant".to_string(),
6630                            model: "counting-stall".to_string(),
6631                        },
6632                    })),
6633                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
6634                        index: 0,
6635                        delta: DeltaPart::Text {
6636                            text: "partial".to_string(),
6637                        },
6638                    })),
6639                ];
6640                Box::pin(futures::stream::iter(events).chain(futures::stream::pending()))
6641            }
6642            fn create_message(
6643                &self,
6644                _request: &crate::api::StreamRequest,
6645            ) -> Pin<
6646                Box<
6647                    dyn std::future::Future<
6648                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6649                        > + Send
6650                        + '_,
6651                >,
6652            > {
6653                Box::pin(async { Err(ApiError::http("unused")) })
6654            }
6655        }
6656
6657        let handler = StreamHandler::new()
6658            .with_timeout_config(StreamTimeoutConfig {
6659                initial_event_timeout: Duration::from_millis(200),
6660                per_event_timeout: Duration::from_millis(200),
6661                total_stream_timeout: Duration::from_millis(400),
6662                max_consecutive_timeouts: 10,
6663                fallback_to_non_streaming: false,
6664            })
6665            .with_retry_config(StreamRetryConfig {
6666                max_retries: 3,
6667                base_delay_ms: 1,
6668                max_delay_ms: 2,
6669                ..Default::default()
6670            });
6671        let cancel = Arc::new(CancelSignal::new());
6672        let request = crate::api::StreamRequest::new(vec![]);
6673        let client = CountingStallMock {
6674            stream_calls: std::sync::atomic::AtomicUsize::new(0),
6675        };
6676        let mut stream = handler.stream_turn(
6677            &client,
6678            &request,
6679            crate::structured::RequestOptions::default(),
6680            &cancel,
6681        );
6682        let mut resets = 0usize;
6683        let mut terminal = None;
6684        while let Some(item) = stream.next().await {
6685            match item {
6686                Ok(HandlerEvent::AttemptReset) => resets += 1,
6687                Ok(_) => {}
6688                Err(e) => {
6689                    terminal = Some(e);
6690                    break;
6691                }
6692            }
6693        }
6694        assert_eq!(
6695            client
6696                .stream_calls
6697                .load(std::sync::atomic::Ordering::SeqCst),
6698            1,
6699            "an expired total deadline must never trigger a second streaming attempt"
6700        );
6701        assert_eq!(
6702            resets, 0,
6703            "no AttemptReset may be emitted when the timeout is terminal"
6704        );
6705        assert!(
6706            matches!(
6707                terminal,
6708                Some(StreamHandlerError::StreamFailed(StreamOutcome::TotalTimeout {
6709                    events_processed,
6710                    ..
6711                })) if events_processed >= 2
6712            ),
6713            "the terminal error must be the mid-stream TotalTimeout with real progress, got {terminal:?}"
6714        );
6715    }
6716
6717    #[tokio::test]
6718    async fn hanging_fallback_is_cut_by_the_fresh_budget() {
6719        struct StallWithHangingFallbackMock {
6720            stream_calls: std::sync::atomic::AtomicUsize,
6721            non_streaming_calls: std::sync::atomic::AtomicUsize,
6722        }
6723        impl ApiClient for StallWithHangingFallbackMock {
6724            fn model(&self) -> String {
6725                "stall-hanging-fallback".to_string()
6726            }
6727            fn stream_messages(
6728                &self,
6729                _request: &crate::api::StreamRequest,
6730            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6731            {
6732                self.stream_calls
6733                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6734                let events = vec![Ok(StreamEvent::MessageStart(MessageStart {
6735                    message: MessageMetadata {
6736                        id: "m1".to_string(),
6737                        role: "assistant".to_string(),
6738                        model: "stall-hanging-fallback".to_string(),
6739                    },
6740                }))];
6741                Box::pin(futures::stream::iter(events).chain(futures::stream::pending()))
6742            }
6743            fn create_message(
6744                &self,
6745                _request: &crate::api::StreamRequest,
6746            ) -> Pin<
6747                Box<
6748                    dyn std::future::Future<
6749                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6750                        > + Send
6751                        + '_,
6752                >,
6753            > {
6754                self.non_streaming_calls
6755                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6756                Box::pin(std::future::pending())
6757            }
6758        }
6759
6760        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
6761            initial_event_timeout: Duration::from_millis(200),
6762            per_event_timeout: Duration::from_millis(200),
6763            total_stream_timeout: Duration::from_millis(400),
6764            max_consecutive_timeouts: 10,
6765            fallback_to_non_streaming: true,
6766        });
6767        let cancel = Arc::new(CancelSignal::new());
6768        let client = StallWithHangingFallbackMock {
6769            stream_calls: std::sync::atomic::AtomicUsize::new(0),
6770            non_streaming_calls: std::sync::atomic::AtomicUsize::new(0),
6771        };
6772        let started = Instant::now();
6773        let err = terminal_error(&handler, &client, &cancel).await;
6774        let elapsed = started.elapsed();
6775        assert_eq!(
6776            client
6777                .stream_calls
6778                .load(std::sync::atomic::Ordering::SeqCst),
6779            1,
6780            "the stalled stream costs one attempt"
6781        );
6782        assert_eq!(
6783            client
6784                .non_streaming_calls
6785                .load(std::sync::atomic::Ordering::SeqCst),
6786            1,
6787            "the fallback must actually start"
6788        );
6789        assert!(
6790            elapsed >= Duration::from_millis(550),
6791            "the fallback must run its fresh initial_event_timeout budget (200ms) after the \
6792             streaming deadline (400ms), not be cut instantly by the expired deadline; elapsed {elapsed:?}"
6793        );
6794        assert!(
6795            elapsed < Duration::from_secs(5),
6796            "the fresh budget must still bound a hanging fallback; elapsed {elapsed:?}"
6797        );
6798        match err {
6799            StreamHandlerError::FallbackFailed { fallback_error, .. } => assert!(
6800                fallback_error.contains("deadline"),
6801                "the fresh budget's expiry must be the failure cause: {fallback_error}"
6802            ),
6803            other => panic!("a hanging fallback must fail as FallbackFailed, got {other:?}"),
6804        }
6805    }
6806
6807    #[tokio::test]
6808    async fn expired_deadline_before_retry_takes_the_fallback() {
6809        struct RetryErrorThenFallbackMock {
6810            stream_calls: std::sync::atomic::AtomicUsize,
6811            non_streaming_calls: std::sync::atomic::AtomicUsize,
6812        }
6813        impl ApiClient for RetryErrorThenFallbackMock {
6814            fn model(&self) -> String {
6815                "retry-then-fallback".to_string()
6816            }
6817            fn stream_messages(
6818                &self,
6819                _request: &crate::api::StreamRequest,
6820            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6821            {
6822                self.stream_calls
6823                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6824                Box::pin(futures::stream::iter(vec![Err(ApiError::http(
6825                    "connection reset",
6826                ))]))
6827            }
6828            fn create_message(
6829                &self,
6830                _request: &crate::api::StreamRequest,
6831            ) -> Pin<
6832                Box<
6833                    dyn std::future::Future<
6834                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6835                        > + Send
6836                        + '_,
6837                >,
6838            > {
6839                self.non_streaming_calls
6840                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6841                Box::pin(async {
6842                    Ok(crate::api::NonStreamingResponse {
6843                        message: Message::new(
6844                            crate::message::Role::Assistant,
6845                            vec![crate::stream::MessagePart::text("fallback answer")],
6846                        ),
6847                        stop_reason: StreamStopReason::EndTurn,
6848                        usage: None,
6849                    })
6850                })
6851            }
6852        }
6853
6854        let handler = StreamHandler::new()
6855            .with_timeout_config(StreamTimeoutConfig {
6856                initial_event_timeout: Duration::from_secs(5),
6857                per_event_timeout: Duration::from_secs(5),
6858                total_stream_timeout: Duration::from_millis(150),
6859                max_consecutive_timeouts: 3,
6860                fallback_to_non_streaming: true,
6861            })
6862            .with_retry_config(StreamRetryConfig {
6863                max_retries: 1,
6864                base_delay_ms: 400,
6865                max_delay_ms: 400,
6866                ..Default::default()
6867            });
6868        let cancel = Arc::new(CancelSignal::new());
6869        let client = RetryErrorThenFallbackMock {
6870            stream_calls: std::sync::atomic::AtomicUsize::new(0),
6871            non_streaming_calls: std::sync::atomic::AtomicUsize::new(0),
6872        };
6873        let request = crate::api::StreamRequest::new(vec![]);
6874        let mut stream = handler.stream_turn(
6875            &client,
6876            &request,
6877            crate::structured::RequestOptions::default(),
6878            &cancel,
6879        );
6880        let mut fell_back = false;
6881        while let Some(item) = stream.next().await {
6882            match item {
6883                Ok(HandlerEvent::Fallback { .. }) => fell_back = true,
6884                Ok(_) => {}
6885                Err(e) => panic!("the expiry must take the fallback, got {e:?}"),
6886            }
6887        }
6888        assert!(
6889            fell_back,
6890            "a deadline expiring before the next retry must reach the non-streaming fallback"
6891        );
6892        let calls = client
6893            .stream_calls
6894            .load(std::sync::atomic::Ordering::SeqCst);
6895        assert_eq!(
6896            calls, 2,
6897            "the retried attempt starts and is cut on its first poll — the expiry is terminal"
6898        );
6899        assert_eq!(
6900            client
6901                .non_streaming_calls
6902                .load(std::sync::atomic::Ordering::SeqCst),
6903            1,
6904            "the fallback must run exactly once"
6905        );
6906    }
6907
6908    #[tokio::test]
6909    async fn per_event_timeout_exhaustion_still_retries() {
6910        struct AlwaysStalledMock {
6911            stream_calls: std::sync::atomic::AtomicUsize,
6912        }
6913        impl ApiClient for AlwaysStalledMock {
6914            fn model(&self) -> String {
6915                "always-stalled".to_string()
6916            }
6917            fn stream_messages(
6918                &self,
6919                _request: &crate::api::StreamRequest,
6920            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6921            {
6922                self.stream_calls
6923                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6924                Box::pin(futures::stream::pending())
6925            }
6926            fn create_message(
6927                &self,
6928                _request: &crate::api::StreamRequest,
6929            ) -> Pin<
6930                Box<
6931                    dyn std::future::Future<
6932                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
6933                        > + Send
6934                        + '_,
6935                >,
6936            > {
6937                Box::pin(async { Err(ApiError::http("no non-streaming path")) })
6938            }
6939        }
6940
6941        let handler = StreamHandler::new()
6942            .with_timeout_config(StreamTimeoutConfig {
6943                initial_event_timeout: Duration::from_millis(50),
6944                per_event_timeout: Duration::from_millis(50),
6945                total_stream_timeout: Duration::from_secs(60),
6946                max_consecutive_timeouts: 2,
6947                fallback_to_non_streaming: false,
6948            })
6949            .with_retry_config(StreamRetryConfig {
6950                max_retries: 1,
6951                base_delay_ms: 1,
6952                max_delay_ms: 2,
6953                ..Default::default()
6954            });
6955        let cancel = Arc::new(CancelSignal::new());
6956        let client = AlwaysStalledMock {
6957            stream_calls: std::sync::atomic::AtomicUsize::new(0),
6958        };
6959        let err = terminal_error(&handler, &client, &cancel).await;
6960        assert_eq!(
6961            client
6962                .stream_calls
6963                .load(std::sync::atomic::Ordering::SeqCst),
6964            2,
6965            "per-event timeout exhaustion keeps the retry ladder: initial + one retry"
6966        );
6967        assert!(
6968            matches!(
6969                err,
6970                StreamHandlerError::StreamFailed(StreamOutcome::EventTimeout { .. })
6971            ),
6972            "the exhausted ladder terminates with the EventTimeout outcome, got {err:?}"
6973        );
6974    }
6975
6976    #[tokio::test]
6977    async fn per_event_stall_still_uses_the_total_deadline() {
6978        struct SlowButHealthyStream;
6979        impl ApiClient for SlowButHealthyStream {
6980            fn model(&self) -> String {
6981                "slow-healthy".to_string()
6982            }
6983            fn stream_messages(
6984                &self,
6985                _request: &crate::api::StreamRequest,
6986            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
6987            {
6988                Box::pin(async_stream::stream! {
6989                    yield Ok(StreamEvent::MessageStart(MessageStart {
6990                        message: MessageMetadata {
6991                            id: "m1".to_string(),
6992                            role: "assistant".to_string(),
6993                            model: "slow-healthy".to_string(),
6994                        },
6995                    }));
6996                    for _ in 0..10 {
6997                        tokio::time::sleep(Duration::from_millis(30)).await;
6998                        yield Ok(StreamEvent::IndexedDelta(IndexedDelta {
6999                            index: 0,
7000                            delta: DeltaPart::Text { text: "chunk".to_string() },
7001                        }));
7002                    }
7003                    yield Ok(StreamEvent::MessageStop);
7004                })
7005            }
7006            fn create_message(
7007                &self,
7008                _request: &crate::api::StreamRequest,
7009            ) -> Pin<
7010                Box<
7011                    dyn std::future::Future<
7012                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
7013                        > + Send
7014                        + '_,
7015                >,
7016            > {
7017                Box::pin(async { Err(ApiError::http("unused")) })
7018            }
7019        }
7020
7021        struct FlakyThenOkStream {
7022            calls: std::sync::atomic::AtomicUsize,
7023        }
7024        impl ApiClient for FlakyThenOkStream {
7025            fn model(&self) -> String {
7026                "flaky-then-ok".to_string()
7027            }
7028            fn stream_messages(
7029                &self,
7030                _request: &crate::api::StreamRequest,
7031            ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>>
7032            {
7033                let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7034                if call == 0 {
7035                    return Box::pin(futures::stream::iter(vec![Err(ApiError::http(
7036                        "connection reset",
7037                    ))]));
7038                }
7039                Box::pin(futures::stream::iter(vec![
7040                    Ok(StreamEvent::MessageStart(MessageStart {
7041                        message: MessageMetadata {
7042                            id: "m2".to_string(),
7043                            role: "assistant".to_string(),
7044                            model: "flaky-then-ok".to_string(),
7045                        },
7046                    })),
7047                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
7048                        index: 0,
7049                        delta: DeltaPart::Text {
7050                            text: "recovered".to_string(),
7051                        },
7052                    })),
7053                    Ok(StreamEvent::MessageStop),
7054                ]))
7055            }
7056            fn create_message(
7057                &self,
7058                _request: &crate::api::StreamRequest,
7059            ) -> Pin<
7060                Box<
7061                    dyn std::future::Future<
7062                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
7063                        > + Send
7064                        + '_,
7065                >,
7066            > {
7067                Box::pin(async { Err(ApiError::http("unused")) })
7068            }
7069        }
7070
7071        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
7072            initial_event_timeout: Duration::from_secs(1),
7073            per_event_timeout: Duration::from_secs(1),
7074            total_stream_timeout: Duration::from_secs(2),
7075            max_consecutive_timeouts: 3,
7076            fallback_to_non_streaming: false,
7077        });
7078        let cancel = Arc::new(CancelSignal::new());
7079        let request = crate::api::StreamRequest::new(vec![]);
7080        {
7081            let mut stream = handler.stream_turn(
7082                &SlowButHealthyStream,
7083                &request,
7084                crate::structured::RequestOptions::default(),
7085                &cancel,
7086            );
7087            let mut stopped = false;
7088            while let Some(item) = stream.next().await {
7089                if let HandlerEvent::Stream(StreamEvent::MessageStop) =
7090                    item.expect("a healthy stream within both budgets must not error")
7091                {
7092                    stopped = true;
7093                }
7094            }
7095            assert!(
7096                stopped,
7097                "a stream producing events under the total budget must complete, not be cut"
7098            );
7099        }
7100
7101        let client = FlakyThenOkStream {
7102            calls: std::sync::atomic::AtomicUsize::new(0),
7103        };
7104        let handler = handler.with_retry_config(StreamRetryConfig {
7105            max_retries: 1,
7106            base_delay_ms: 1,
7107            max_delay_ms: 2,
7108            ..Default::default()
7109        });
7110        let mut stream = handler.stream_turn(
7111            &client,
7112            &request,
7113            crate::structured::RequestOptions::default(),
7114            &cancel,
7115        );
7116        let mut stopped = false;
7117        while let Some(item) = stream.next().await {
7118            if let HandlerEvent::Stream(StreamEvent::MessageStop) =
7119                item.expect("a retried-then-successful stream must not error")
7120            {
7121                stopped = true;
7122            }
7123        }
7124        assert!(stopped, "the recovered attempt must complete the turn");
7125        assert_eq!(
7126            client.calls.load(std::sync::atomic::Ordering::SeqCst),
7127            2,
7128            "exactly one retry, then success"
7129        );
7130    }
7131
7132    #[tokio::test]
7133    async fn cancelled_stream_is_not_retried() {
7134        let client = StalledStreamClient {
7135            stream_calls: std::sync::atomic::AtomicUsize::new(0),
7136        };
7137        let handler = StreamHandler::new()
7138            .with_retry_config(StreamRetryConfig {
7139                max_retries: 3,
7140                base_delay_ms: 1,
7141                max_delay_ms: 2,
7142                ..Default::default()
7143            })
7144            .with_timeout_config(StreamTimeoutConfig {
7145                initial_event_timeout: Duration::from_secs(5),
7146                per_event_timeout: Duration::from_secs(5),
7147                total_stream_timeout: Duration::from_secs(60),
7148                max_consecutive_timeouts: 3,
7149                fallback_to_non_streaming: true,
7150            });
7151        let cancel = Arc::new(CancelSignal::new());
7152        let cancel_for_task = Arc::clone(&cancel);
7153        tokio::spawn(async move {
7154            tokio::task::yield_now().await;
7155            cancel_for_task.cancel();
7156        });
7157        let err = terminal_error(&handler, &client, &cancel).await;
7158        assert_eq!(
7159            client
7160                .stream_calls
7161                .load(std::sync::atomic::Ordering::SeqCst),
7162            1,
7163            "cancellation mid-stream must not re-enter the retry ladder"
7164        );
7165        assert!(
7166            matches!(err, StreamHandlerError::Cancelled),
7167            "the terminal error must be the cancellation, got {err:?}"
7168        );
7169    }
7170
7171    #[tokio::test]
7172    async fn mid_stream_total_timeout_reports_real_progress() {
7173        struct StallAfterEventsMock;
7174
7175        impl ApiClient for StallAfterEventsMock {
7176            fn model(&self) -> String {
7177                "stall".to_string()
7178            }
7179            fn stream_messages(
7180                &self,
7181                _request: &crate::api::StreamRequest,
7182            ) -> std::pin::Pin<
7183                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
7184            > {
7185                let events = vec![
7186                    Ok(StreamEvent::MessageStart(MessageStart {
7187                        message: MessageMetadata {
7188                            id: "m1".to_string(),
7189                            role: "assistant".to_string(),
7190                            model: "stall".to_string(),
7191                        },
7192                    })),
7193                    Ok(StreamEvent::PartStart(PartStart {
7194                        index: 0,
7195                        part: Some(crate::stream::MessagePart::text("")),
7196                    })),
7197                    Ok(StreamEvent::IndexedDelta(IndexedDelta {
7198                        index: 0,
7199                        delta: DeltaPart::Text {
7200                            text: "hi".to_string(),
7201                        },
7202                    })),
7203                ];
7204                Box::pin(futures::stream::iter(events).chain(futures::stream::pending()))
7205            }
7206            fn create_message(
7207                &self,
7208                _request: &crate::api::StreamRequest,
7209            ) -> std::pin::Pin<
7210                Box<
7211                    dyn std::future::Future<
7212                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
7213                        > + Send
7214                        + '_,
7215                >,
7216            > {
7217                Box::pin(async { Err(ApiError::http_with_status(500, "no non-streaming")) })
7218            }
7219        }
7220
7221        let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig {
7222            initial_event_timeout: Duration::from_millis(100),
7223            per_event_timeout: Duration::from_millis(100),
7224            total_stream_timeout: Duration::from_millis(500),
7225            max_consecutive_timeouts: 10,
7226            fallback_to_non_streaming: false,
7227        });
7228        let cancel = Arc::new(CancelSignal::new());
7229        let req = crate::api::StreamRequest::new(vec![]);
7230        let mut stream = handler.stream_turn(
7231            &StallAfterEventsMock,
7232            &req,
7233            crate::structured::RequestOptions::default(),
7234            &cancel,
7235        );
7236        let mut streamed = 0usize;
7237        let mut terminal = None;
7238        while let Some(item) = stream.next().await {
7239            match item {
7240                Ok(HandlerEvent::Stream(_)) => streamed += 1,
7241                Err(e) => {
7242                    terminal = Some(e);
7243                    break;
7244                }
7245                Ok(_) => {}
7246            }
7247        }
7248        assert!(streamed >= 3, "the stream processed real events first");
7249        match terminal.expect("stream must terminate with an error") {
7250            StreamHandlerError::StreamFailed(StreamOutcome::TotalTimeout {
7251                events_processed,
7252                ..
7253            }) => assert!(
7254                events_processed >= 3,
7255                "doc: events_processed counts accepted events before the deadline — zero implies an immediate stall"
7256            ),
7257            other => panic!(
7258                "a mid-stream deadline is a StreamFailed TotalTimeout, got {other:?} after {streamed} events"
7259            ),
7260        }
7261    }
7262
7263    #[tokio::test]
7264    async fn total_timeout_duration_covers_retried_attempts() {
7265        use std::sync::atomic::{AtomicUsize, Ordering};
7266
7267        struct FailThenStallMock {
7268            calls: AtomicUsize,
7269        }
7270
7271        impl ApiClient for FailThenStallMock {
7272            fn model(&self) -> String {
7273                "flaky".to_string()
7274            }
7275            fn stream_messages(
7276                &self,
7277                _request: &crate::api::StreamRequest,
7278            ) -> std::pin::Pin<
7279                Box<dyn futures::Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>,
7280            > {
7281                let call = self.calls.fetch_add(1, Ordering::SeqCst);
7282                if call == 0 {
7283                    let opening = futures::stream::once(async {
7284                        Ok(StreamEvent::MessageStart(MessageStart {
7285                            message: MessageMetadata {
7286                                id: "m1".to_string(),
7287                                role: "assistant".to_string(),
7288                                model: "flaky".to_string(),
7289                            },
7290                        }))
7291                    });
7292                    let kept_alive = opening.chain(futures::stream::once(async {
7293                        tokio::time::sleep(Duration::from_millis(150)).await;
7294                        Ok(StreamEvent::IndexedDelta(IndexedDelta {
7295                            index: 0,
7296                            delta: DeltaPart::Text {
7297                                text: "chunk".to_string(),
7298                            },
7299                        }))
7300                    }));
7301                    Box::pin(kept_alive.chain(futures::stream::once(async {
7302                        tokio::time::sleep(Duration::from_millis(1200)).await;
7303                        Err(ApiError::http_with_status(500, "transient boom"))
7304                    })))
7305                } else {
7306                    Box::pin(futures::stream::pending())
7307                }
7308            }
7309            fn create_message(
7310                &self,
7311                _request: &crate::api::StreamRequest,
7312            ) -> std::pin::Pin<
7313                Box<
7314                    dyn std::future::Future<
7315                            Output = Result<crate::api::NonStreamingResponse, ApiError>,
7316                        > + Send
7317                        + '_,
7318                >,
7319            > {
7320                Box::pin(async { Err(ApiError::http_with_status(500, "no non-streaming")) })
7321            }
7322        }
7323
7324        let handler = StreamHandler::new()
7325            .with_timeout_config(StreamTimeoutConfig {
7326                initial_event_timeout: Duration::from_millis(500),
7327                per_event_timeout: Duration::from_millis(500),
7328                total_stream_timeout: Duration::from_secs(2),
7329                max_consecutive_timeouts: 10,
7330                fallback_to_non_streaming: false,
7331            })
7332            .with_retry_config(StreamRetryConfig {
7333                max_retries: 1,
7334                ..Default::default()
7335            });
7336        let client = FailThenStallMock {
7337            calls: AtomicUsize::new(0),
7338        };
7339        let cancel = Arc::new(CancelSignal::new());
7340        let req = crate::api::StreamRequest::new(vec![]);
7341        let mut stream = handler.stream_turn(
7342            &client,
7343            &req,
7344            crate::structured::RequestOptions::default(),
7345            &cancel,
7346        );
7347        let mut terminal = None;
7348        while let Some(item) = stream.next().await {
7349            if let Err(e) = item {
7350                terminal = Some(e);
7351                break;
7352            }
7353        }
7354        match terminal.expect("stream must terminate with an error") {
7355            StreamHandlerError::StreamFailed(StreamOutcome::TotalTimeout { duration, .. }) => {
7356                assert!(
7357                    duration >= Duration::from_millis(1500),
7358                    "doc: duration is the full stream lifetime, approximately the configured total (2s); got {duration:?}"
7359                );
7360            }
7361            other => panic!("expected StreamFailed TotalTimeout, got {other:?}"),
7362        }
7363    }
7364}