use std::future::Future;
use std::pin::Pin;
use bytes::Bytes;
use eventsource_stream::{Event, EventStreamError, Eventsource};
use futures::{Stream, StreamExt, stream};
use crate::error::AgentLoopError;
use crate::llm_retry::{LlmRetryConfig, RetryMetadata};
pub type SseItem = Result<Event, EventStreamError<reqwest::Error>>;
pub type SseStream = Pin<Box<dyn Stream<Item = SseItem> + Send>>;
pub type ByteStream = Pin<Box<dyn Stream<Item = reqwest::Result<Bytes>> + Send>>;
pub fn is_reconnectable_reqwest_error(err: &reqwest::Error) -> bool {
err.is_body() || err.is_decode() || err.is_connect() || err.is_request() || err.is_timeout()
}
pub fn is_reconnectable_stream_error(err: &EventStreamError<reqwest::Error>) -> bool {
match err {
EventStreamError::Transport(e) => is_reconnectable_reqwest_error(e),
EventStreamError::Utf8(_) | EventStreamError::Parser(_) => false,
}
}
pub async fn connect_sse_with_reconnect<C, Fut>(
retry_config: &LlmRetryConfig,
driver_name: &str,
mut connect: C,
) -> Result<(SseStream, RetryMetadata), AgentLoopError>
where
C: FnMut(u32) -> Fut,
Fut: Future<Output = Result<(reqwest::Response, RetryMetadata), AgentLoopError>>,
{
let mut attempt: u32 = 0;
loop {
let (response, metadata) = connect(attempt).await?;
let events = Box::pin(response.bytes_stream().eventsource());
let (first, rest) = events.into_future().await;
let reconnectable = matches!(&first, Some(Err(e)) if is_reconnectable_stream_error(e));
if reconnectable && attempt < retry_config.max_retries {
attempt += 1;
let wait = retry_config.calculate_backoff(attempt - 1);
if let Some(Err(e)) = &first {
tracing::warn!(
driver = driver_name,
attempt,
max_retries = retry_config.max_retries,
wait_secs = wait.as_secs_f64(),
error = %e,
"streaming transport failed before first event; reconnecting"
);
}
tokio::time::sleep(wait).await;
continue;
}
if attempt > 0 && !reconnectable {
tracing::info!(
driver = driver_name,
reconnects = attempt,
"streaming reconnect succeeded"
);
}
return Ok((Box::pin(stream::iter(first).chain(rest)), metadata));
}
}
pub async fn connect_bytes_with_reconnect<C, Fut>(
retry_config: &LlmRetryConfig,
driver_name: &str,
mut connect: C,
) -> Result<(ByteStream, RetryMetadata), AgentLoopError>
where
C: FnMut(u32) -> Fut,
Fut: Future<Output = Result<(reqwest::Response, RetryMetadata), AgentLoopError>>,
{
let mut attempt: u32 = 0;
loop {
let (response, metadata) = connect(attempt).await?;
let bytes = Box::pin(response.bytes_stream());
let (first, rest) = bytes.into_future().await;
let reconnectable = matches!(&first, Some(Err(e)) if is_reconnectable_reqwest_error(e));
if reconnectable && attempt < retry_config.max_retries {
attempt += 1;
let wait = retry_config.calculate_backoff(attempt - 1);
if let Some(Err(e)) = &first {
tracing::warn!(
driver = driver_name,
attempt,
max_retries = retry_config.max_retries,
wait_secs = wait.as_secs_f64(),
error = %e,
"streaming transport failed before first chunk; reconnecting"
);
}
tokio::time::sleep(wait).await;
continue;
}
if attempt > 0 && !reconnectable {
tracing::info!(
driver = driver_name,
reconnects = attempt,
"streaming reconnect succeeded"
);
}
return Ok((Box::pin(stream::iter(first).chain(rest)), metadata));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[derive(Clone, Copy)]
enum Behavior {
TruncateBeforeEvent,
EventThenTruncate,
FullSse,
}
const CONTENT_EVENT: &str = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
const DONE_EVENT: &str = "data: [DONE]\n\n";
fn chunk(body: &str) -> String {
format!("{:x}\r\n{}\r\n", body.len(), body)
}
async fn spawn_scripted_sse_server(behaviors: Vec<Behavior>) -> (String, Arc<AtomicU32>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let counter = Arc::new(AtomicU32::new(0));
let counter_task = Arc::clone(&counter);
tokio::spawn(async move {
loop {
let (mut socket, _) = match listener.accept().await {
Ok(pair) => pair,
Err(_) => break,
};
let idx = counter_task.fetch_add(1, Ordering::SeqCst) as usize;
let behavior = behaviors[idx.min(behaviors.len() - 1)];
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n";
let _ = socket.write_all(headers.as_bytes()).await;
match behavior {
Behavior::TruncateBeforeEvent => {
let _ = socket.write_all(b"5\r\nda").await;
}
Behavior::EventThenTruncate => {
let _ = socket.write_all(chunk(CONTENT_EVENT).as_bytes()).await;
let _ = socket.write_all(b"5\r\nda").await;
}
Behavior::FullSse => {
let _ = socket.write_all(chunk(CONTENT_EVENT).as_bytes()).await;
let _ = socket.write_all(chunk(DONE_EVENT).as_bytes()).await;
let _ = socket.write_all(b"0\r\n\r\n").await;
}
}
let _ = socket.flush().await;
}
});
(format!("http://{addr}/"), counter)
}
fn fast_config(max_retries: u32) -> LlmRetryConfig {
LlmRetryConfig {
max_retries,
initial_backoff: Duration::from_millis(0),
max_backoff: Duration::from_millis(0),
backoff_multiplier: 1.0,
jitter_factor: 0.0,
}
}
async fn collect_via_reconnect(base: &str, config: &LlmRetryConfig) -> Vec<SseItem> {
let client = reqwest::Client::new();
let (stream, _meta) = connect_sse_with_reconnect(config, "test", |_attempt| {
let client = client.clone();
let base = base.to_string();
async move {
let resp = client
.get(&base)
.send()
.await
.map_err(|e| AgentLoopError::llm(e.to_string()))?;
Ok((resp, RetryMetadata::default()))
}
})
.await
.expect("connect should not terminally fail");
stream.collect().await
}
#[tokio::test]
async fn reconnects_on_truncated_first_then_succeeds() {
let (base, count) =
spawn_scripted_sse_server(vec![Behavior::TruncateBeforeEvent, Behavior::FullSse]).await;
let items = collect_via_reconnect(&base, &fast_config(2)).await;
assert_eq!(
count.load(Ordering::SeqCst),
2,
"should reconnect exactly once"
);
let texts: Vec<String> = items
.iter()
.filter_map(|i| i.as_ref().ok())
.map(|ev| ev.data.clone())
.collect();
assert!(
texts.iter().any(|d| d.contains("hi")),
"expected content event after reconnect, got {texts:?}"
);
assert!(
items.iter().all(|i| i.is_ok()),
"reconnected stream should carry no transport error"
);
}
#[tokio::test]
async fn exhausts_reconnects_and_surfaces_error() {
let (base, count) = spawn_scripted_sse_server(vec![Behavior::TruncateBeforeEvent]).await;
let items = collect_via_reconnect(&base, &fast_config(2)).await;
assert_eq!(
count.load(Ordering::SeqCst),
3,
"should exhaust max_retries"
);
assert!(
items.last().is_some_and(|i| i.is_err()),
"exhausted stream should surface the transport error, got {items:?}"
);
}
#[tokio::test]
async fn clean_stream_makes_single_connection() {
let (base, count) = spawn_scripted_sse_server(vec![Behavior::FullSse]).await;
let items = collect_via_reconnect(&base, &fast_config(2)).await;
assert_eq!(
count.load(Ordering::SeqCst),
1,
"healthy stream must not reconnect"
);
assert!(items.iter().all(|i| i.is_ok()));
assert!(items.iter().filter_map(|i| i.as_ref().ok()).count() >= 1);
}
#[tokio::test]
async fn error_after_first_event_passes_through_without_reconnect() {
let (base, count) = spawn_scripted_sse_server(vec![Behavior::EventThenTruncate]).await;
let items = collect_via_reconnect(&base, &fast_config(2)).await;
assert_eq!(
count.load(Ordering::SeqCst),
1,
"committed stream must not reconnect after emitting an event"
);
assert!(
items.first().is_some_and(|i| i.is_ok()),
"first item should be the committed event"
);
assert!(
items.last().is_some_and(|i| i.is_err()),
"trailing transport error should pass through"
);
}
#[test]
fn classifier_rejects_non_transport_errors() {
let utf8_err = String::from_utf8(vec![0xff, 0xfe]).unwrap_err();
let err: EventStreamError<reqwest::Error> = EventStreamError::Utf8(utf8_err);
assert!(!is_reconnectable_stream_error(&err));
}
}