1use 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#[derive(Debug, Clone)]
77pub struct StreamTimeoutConfig {
78 pub initial_event_timeout: Duration,
84
85 pub per_event_timeout: Duration,
91
92 pub total_stream_timeout: Duration,
97
98 pub max_consecutive_timeouts: u32,
103
104 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 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#[derive(Debug, Clone)]
204pub struct StreamRetryConfig {
205 pub max_retries: u32,
210
211 pub base_delay_ms: u64,
215
216 pub max_delay_ms: u64,
220
221 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 #[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 #[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 #[must_use]
308 fn random_signed_fraction() -> f64 {
309 (fastrand::f64() - 0.5) * 2.0
310 }
311
312 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#[derive(Debug, Clone)]
385pub struct RateLimitConfig {
386 pub respect_retry_after: bool,
394
395 pub default_delay: Duration,
402
403 pub max_delay: Duration,
409
410 pub requests_per_minute: u32,
421
422 pub fallback_after_retries: u32,
427
428 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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub enum RateLimitKind {
518 RateLimited,
525
526 Overloaded,
533}
534
535#[derive(Debug, Clone)]
541pub struct DetectedRateLimit {
542 pub kind: RateLimitKind,
549
550 pub retry_after: Option<Duration>,
556
557 pub message: String,
564}
565
566impl DetectedRateLimit {
567 #[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
623fn 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#[derive(Debug)]
658enum RateLimitRetry {
659 Escalate {
667 attempts: u32,
676
677 retry_after: Option<Duration>,
686 },
687
688 HardStop,
702
703 Retry(Duration),
713}
714
715enum ErrorAction {
728 Fail(StreamHandlerError),
734
735 TryFallback(Option<StreamOutcome>),
743
744 Retry(Duration),
750}
751
752struct StreamFailure {
761 error: StreamHandlerError,
766
767 retryable: bool,
773}
774
775impl StreamFailure {
776 fn transient(error: StreamHandlerError) -> Self {
782 Self {
783 error,
784 retryable: true,
785 }
786 }
787}
788
789fn 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
808async 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
825async 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
848enum EventPoll {
858 Next(Option<Result<crate::stream::StreamEvent, crate::api::error::ApiError>>),
864
865 TimedOut,
873}
874
875struct EventDiagnostics {
881 events_processed: u64,
889
890 stream_start: Instant,
898
899 has_partial_data: bool,
909
910 attempts_so_far: u32,
919}
920
921impl EventDiagnostics {
922 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 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 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 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#[derive(Debug, Clone)]
1021#[non_exhaustive]
1022pub enum StreamOutcome {
1023 Completed {
1028 events_processed: u64,
1034
1035 duration: Duration,
1042 },
1043
1044 TotalTimeout {
1050 has_partial_data: bool,
1056
1057 events_processed: u64,
1063
1064 duration: Duration,
1071 },
1072
1073 EventTimeout {
1079 has_partial_data: bool,
1086
1087 consecutive_timeouts: u32,
1095 },
1096
1097 RateLimited {
1104 detail: DetectedRateLimit,
1112
1113 has_partial_data: bool,
1119
1120 events_processed: u64,
1126 },
1127
1128 InitFailed {
1139 last_error: String,
1146
1147 attempts: u32,
1154 },
1155
1156 FallbackToNonStreaming,
1161
1162 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#[derive(Debug)]
1258#[non_exhaustive]
1259pub enum StreamHandlerError {
1260 InitFailed(StreamOutcome),
1268
1269 StreamFailed(StreamOutcome),
1274
1275 FallbackFailed {
1280 stream_outcome: StreamOutcome,
1287
1288 fallback_error: String,
1296 },
1297
1298 Cancelled,
1303
1304 Poisoned(&'static str),
1310
1311 RateLimitEscalation {
1319 attempts: u32,
1328
1329 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
1368pub struct StreamHandler {
1417 timeout_config: StreamTimeoutConfig,
1424
1425 retry_config: StreamRetryConfig,
1432
1433 rate_limit_config: RateLimitConfig,
1440
1441 rate_limiter: Option<Arc<crate::stream::rate_limit::RateLimiter>>,
1448
1449 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 #[must_use]
1503 pub fn passthrough() -> Self {
1504 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 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 #[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 #[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 #[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 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 #[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 #[must_use]
1683 pub fn timeout_config(&self) -> &StreamTimeoutConfig {
1684 &self.timeout_config
1685 }
1686
1687 #[must_use]
1692 pub fn retry_config(&self) -> &StreamRetryConfig {
1693 &self.retry_config
1694 }
1695
1696 #[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 #[must_use]
1729 pub fn rate_limit_config(&self) -> &RateLimitConfig {
1730 &self.rate_limit_config
1731 }
1732
1733 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
2433#[non_exhaustive]
2434pub enum HandlerEvent {
2435 Stream(StreamEvent),
2442
2443 AttemptReset,
2451
2452 Fallback {
2466 message: Message,
2476
2477 stop_reason: StreamStopReason,
2484
2485 usage: Option<Usage>,
2491 },
2492}
2493
2494#[cfg(test)]
2495mod tests {
2496 use super::*;
2497
2498 #[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 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 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 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 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 assert_eq!(stop_reason, StreamStopReason::EndTurn);
3187 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 handler
4764 .gate_on_rate_limit(&client, &cancel, None)
4765 .await
4766 .expect("first acquire should succeed (full bucket)");
4767
4768 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 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 handler
4803 .gate_on_rate_limit(&client, &cancel, None)
4804 .await
4805 .expect("first acquire should succeed (full bucket)");
4806
4807 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 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 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 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 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 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 handler
4905 .drive_turn(&client, &crate::api::StreamRequest::new(vec![]), &cancel)
4906 .await
4907 .expect("first turn should succeed");
4908
4909 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
5790 elapsed < Duration::from_secs(2),
5791 "deadline clamp should prevent a 600s sleep; elapsed {elapsed:?}",
5792 );
5793 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 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 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 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 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}