Skip to main content

rig_core/http_client/
sse.rs

1//! An SSE implementation that leverages [`crate::http_client::HttpClientExt`] to allow streaming with automatic retry handling for any implementor of HttpClientExt.
2//!
3//! Primarily intended for internal usage. However if you also wish to implement generic HTTP streaming for your custom completion model,
4//! you may find this helpful.
5use crate::{
6    http_client::{
7        HttpClientExt, Result as StreamResult,
8        retry::{DEFAULT_RETRY, ExponentialBackoff, RetryPolicy},
9    },
10    wasm_compat::{WasmCompatSend, WasmCompatSendStream},
11};
12use bytes::Bytes;
13use eventsource_stream::{Event as MessageEvent, EventStreamError, Eventsource};
14use futures::Stream;
15#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
16use futures::{future::BoxFuture, stream::BoxStream};
17#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
18use futures::{future::LocalBoxFuture, stream::LocalBoxStream};
19use futures_timer::Delay;
20use http::Response;
21use http::{HeaderName, HeaderValue, Request, StatusCode};
22use mime_guess::mime;
23use pin_project_lite::pin_project;
24use std::{
25    pin::Pin,
26    task::{Context, Poll},
27    time::Duration,
28};
29
30pub type BoxedStream = Pin<Box<dyn WasmCompatSendStream<InnerItem = StreamResult<Bytes>>>>;
31
32#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
33type ResponseFuture = BoxFuture<'static, Result<Response<BoxedStream>, super::Error>>;
34#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
35type ResponseFuture = LocalBoxFuture<'static, Result<Response<BoxedStream>, super::Error>>;
36
37#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
38type EventStream = BoxStream<'static, Result<MessageEvent, EventStreamError<super::Error>>>;
39#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
40type EventStream = LocalBoxStream<'static, Result<MessageEvent, EventStreamError<super::Error>>>;
41
42pin_project! {
43    /// Internal state variants for the SSE state machine.
44    #[project = SourceStateProjection]
45    enum SourceState {
46        /// A connection attempt in flight, carrying the retry that produced it
47        /// — `None` for the initial connect. The history belongs in the state
48        /// rather than in a separate `Reconnecting` variant because it is the
49        /// only thing a reconnect ever did differently: everything else (the
50        /// response check, the request-id capture, the handoff to `Open`) was
51        /// identical, so two variants meant two copies of it.
52        Connecting {
53            #[pin]
54            response_future: ResponseFuture,
55            last_retry: Option<(usize, Duration)>,
56        },
57        /// Actively receiving SSE events
58        Open {
59            #[pin]
60            event_stream: EventStream,
61        },
62        /// Waiting before retry after an error
63        WaitingToRetry {
64            #[pin]
65            retry_delay: Delay,
66            current_retry: (usize, Duration),
67        },
68        /// Terminal state
69        Closed,
70    }
71}
72
73/// Shared slot for the transport request id captured off an SSE connection's
74/// response headers. Overwritten on every successful (re)connect — with
75/// `None` when that connection's response omits (or garbles) the header — so
76/// a reader at stream end sees the id of exactly the connection that
77/// delivered the terminal, never a previous connection's.
78pub type RequestIdSlot = std::sync::Arc<std::sync::Mutex<Option<String>>>;
79
80pin_project! {
81    /// A generic SSE event source that works with any [`HttpClientExt`] implementation.
82    #[project = GenericEventSourceProjection]
83    pub struct GenericEventSource<HttpClient, RequestBody, Retry = ExponentialBackoff> {
84        client: HttpClient,
85        req: Request<RequestBody>,
86        retry_policy: Retry,
87        last_event_id: Option<String>,
88        allow_missing_content_type: bool,
89        request_id_capture: Option<(String, RequestIdSlot)>,
90        #[pin]
91        state: SourceState,
92    }
93}
94
95impl<HttpClient, RequestBody> GenericEventSource<HttpClient, RequestBody>
96where
97    HttpClient: HttpClientExt + Clone + 'static,
98    RequestBody: Into<Bytes> + Clone + WasmCompatSend + 'static,
99{
100    /// Create a new event source that will connect to the given request.
101    pub fn new(client: HttpClient, req: Request<RequestBody>) -> Self {
102        let response_future = Self::create_response_future(&client, &req, None);
103        let state = SourceState::Connecting {
104            response_future,
105            last_retry: None,
106        };
107
108        Self {
109            client,
110            req,
111            retry_policy: DEFAULT_RETRY,
112            last_event_id: None,
113            allow_missing_content_type: false,
114            request_id_capture: None,
115            state,
116        }
117    }
118
119    pub fn allow_missing_content_type(mut self) -> Self {
120        self.allow_missing_content_type = true;
121        self
122    }
123
124    /// Capture the named response header from each successful (re)connect into
125    /// the returned [`RequestIdSlot`]. Each (re)connect *replaces* the slot —
126    /// a connection whose response omits the header resets it to `None`, so a
127    /// stale id from a previous connection is never attributed to the one
128    /// that delivered the terminal.
129    pub fn capture_request_id(mut self, header: impl Into<String>) -> (Self, RequestIdSlot) {
130        let slot = RequestIdSlot::default();
131        self.request_id_capture = Some((header.into(), slot.clone()));
132        (self, slot)
133    }
134
135    /// Create a response future for connecting/reconnecting
136    fn create_response_future(
137        client: &HttpClient,
138        req: &Request<RequestBody>,
139        last_event_id: Option<&str>,
140    ) -> ResponseFuture {
141        let mut req_clone = req.clone();
142        req_clone
143            .headers_mut()
144            .entry("Accept")
145            .or_insert(HeaderValue::from_static("text/event-stream"));
146
147        if let Some(id) = last_event_id
148            && let Ok(value) = HeaderValue::from_str(id)
149        {
150            req_clone
151                .headers_mut()
152                .insert(HeaderName::from_static("last-event-id"), value);
153        }
154
155        let client_clone = client.clone();
156        Box::pin(async move { client_clone.send_streaming(req_clone).await })
157    }
158
159    /// Get the last event id
160    pub fn last_event_id(&self) -> Option<&str> {
161        self.last_event_id.as_deref()
162    }
163
164    /// Close the event source, transitioning to the Closed state.
165    /// After calling this, the stream will yield `None` on the next poll.
166    pub fn close(&mut self) {
167        self.state = SourceState::Closed;
168    }
169}
170
171/// Events created by the [`GenericEventSource`]
172#[derive(Debug, Clone, Eq, PartialEq)]
173pub enum Event {
174    /// The event fired when the connection is opened
175    Open,
176    /// The event fired when a [`MessageEvent`] is received
177    Message(MessageEvent),
178}
179
180impl From<MessageEvent> for Event {
181    fn from(event: MessageEvent) -> Self {
182        Event::Message(event)
183    }
184}
185
186impl<HttpClient, RequestBody> Stream for GenericEventSource<HttpClient, RequestBody>
187where
188    HttpClient: HttpClientExt + Clone + 'static,
189    RequestBody: Into<Bytes> + Clone + WasmCompatSend + 'static,
190{
191    type Item = Result<Event, super::Error>;
192
193    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
194        let mut this = self.project();
195
196        loop {
197            match this.state.as_mut().project() {
198                SourceStateProjection::Connecting {
199                    response_future,
200                    last_retry,
201                } => {
202                    // Copied out before the poll so the state projection's
203                    // borrow ends before the transition writes `this.state`.
204                    let last_retry = *last_retry;
205                    match response_future.poll(cx) {
206                        Poll::Pending => return Poll::Pending,
207                        Poll::Ready(Ok(response)) => {
208                            match check_response(response, *this.allow_missing_content_type) {
209                                Ok(response) => {
210                                    // Transition: Connecting -> Open
211                                    capture_request_id_header(
212                                        this.request_id_capture.as_ref(),
213                                        &response,
214                                    );
215                                    let mut event_stream = response.into_body().eventsource();
216                                    if let Some(id) = &this.last_event_id {
217                                        event_stream.set_last_event_id(id.clone());
218                                    }
219                                    this.state.set(SourceState::Open {
220                                        event_stream: Box::pin(event_stream),
221                                    });
222                                    return Poll::Ready(Some(Ok(Event::Open)));
223                                }
224                                Err(err) => {
225                                    // Transition: Connecting -> Closed. A rejected
226                                    // response is terminal: the retry policy governs
227                                    // transport failures, not a server that answered.
228                                    this.state.set(SourceState::Closed);
229                                    return Poll::Ready(Some(Err(err)));
230                                }
231                            }
232                        }
233                        Poll::Ready(Err(err)) => {
234                            // Transition: Connecting -> WaitingToRetry or Closed,
235                            // continuing the retry cycle `last_retry` describes.
236                            this.state.set(state_after_transport_error(
237                                this.retry_policy,
238                                &err,
239                                last_retry,
240                            ));
241                            return Poll::Ready(Some(Err(err)));
242                        }
243                    }
244                }
245
246                SourceStateProjection::Open { event_stream } => {
247                    match event_stream.poll_next(cx) {
248                        Poll::Pending => return Poll::Pending,
249                        Poll::Ready(Some(Ok(event))) => {
250                            if !event.id.is_empty() {
251                                *this.last_event_id = Some(event.id.clone());
252                            }
253                            if let Some(duration) = event.retry {
254                                this.retry_policy.set_reconnection_time(duration);
255                            }
256                            return Poll::Ready(Some(Ok(Event::Message(event))));
257                        }
258                        Poll::Ready(Some(Err(EventStreamError::Transport(err)))) => {
259                            // Transition: Open -> WaitingToRetry or Closed. A
260                            // failure mid-stream starts a *fresh* cycle (history
261                            // `None`): this connection had already succeeded, so
262                            // the attempts that preceded it no longer apply.
263                            this.state.set(state_after_transport_error(
264                                this.retry_policy,
265                                &err,
266                                None,
267                            ));
268                            return Poll::Ready(Some(Err(err)));
269                        }
270                        Poll::Ready(Some(Err(EventStreamError::Parser(_)))) => {
271                            // Parser errors are recoverable - continue polling
272                            continue;
273                        }
274                        Poll::Ready(Some(Err(EventStreamError::Utf8(_)))) => {
275                            // UTF-8 errors are recoverable - continue polling
276                            continue;
277                        }
278                        Poll::Ready(None) => {
279                            // Transition: Open -> Closed
280                            this.state.set(SourceState::Closed);
281                            return Poll::Ready(None);
282                        }
283                    }
284                }
285
286                SourceStateProjection::WaitingToRetry {
287                    retry_delay,
288                    current_retry,
289                } => {
290                    // Copy before polling to avoid borrow conflicts
291                    let retry_info = *current_retry;
292                    match retry_delay.poll(cx) {
293                        Poll::Pending => return Poll::Pending,
294                        Poll::Ready(()) => {
295                            // Transition: WaitingToRetry -> Connecting
296                            let response_future =
297                                GenericEventSource::<HttpClient, RequestBody>::create_response_future(
298                                    this.client,
299                                    this.req,
300                                    this.last_event_id.as_deref(),
301                                );
302                            this.state.set(SourceState::Connecting {
303                                response_future,
304                                last_retry: Some(retry_info),
305                            });
306                            continue;
307                        }
308                    }
309                }
310
311                SourceStateProjection::Closed => {
312                    return Poll::Ready(None);
313                }
314            }
315        }
316    }
317}
318
319/// The state a transport failure moves the machine to: wait out the policy's
320/// next delay, or close when it declines to retry.
321///
322/// `last_retry` is the retry that produced the failed attempt, so the retry
323/// number the policy sees and the one recorded for the next attempt advance
324/// together — the numbering is stated once instead of per call site.
325fn state_after_transport_error(
326    retry_policy: &impl RetryPolicy,
327    error: &super::Error,
328    last_retry: Option<(usize, Duration)>,
329) -> SourceState {
330    match retry_policy.retry(error, last_retry) {
331        Some(delay) => SourceState::WaitingToRetry {
332            retry_delay: Delay::new(delay),
333            current_retry: (last_retry.map_or(1, |(retry_num, _)| retry_num + 1), delay),
334        },
335        None => SourceState::Closed,
336    }
337}
338
339/// Replace the shared slot with this connection's request-id header value —
340/// `None` when the response omits the header or its value is empty/invalid.
341/// Overwriting (rather than only writing on presence) is what prevents a
342/// reconnect from reporting the *previous* connection's id.
343fn capture_request_id_header<T>(capture: Option<&(String, RequestIdSlot)>, response: &Response<T>) {
344    if let Some((header, slot)) = capture
345        && let Ok(mut slot) = slot.lock()
346    {
347        *slot = response
348            .headers()
349            .get(header.as_str())
350            .and_then(|value| value.to_str().ok())
351            .filter(|value| !value.is_empty())
352            .map(str::to_string);
353    }
354}
355
356fn check_response<T>(
357    response: Response<T>,
358    allow_missing_content_type: bool,
359) -> Result<Response<T>, super::Error> {
360    let StatusCode::OK = response.status() else {
361        return Err(super::Error::InvalidStatusCode(response.status()));
362    };
363
364    let content_type =
365        if let Some(content_type) = response.headers().get(&reqwest::header::CONTENT_TYPE) {
366            content_type
367        } else if allow_missing_content_type {
368            return Ok(response);
369        } else {
370            return Err(super::Error::InvalidContentType(HeaderValue::from_static(
371                "",
372            )));
373        };
374
375    if content_type
376        .to_str()
377        .map_err(|_| ())
378        .and_then(|s| s.parse::<mime::Mime>().map_err(|_| ()))
379        .map(|mime_type| {
380            matches!(
381                (mime_type.type_(), mime_type.subtype()),
382                (mime::TEXT, mime::EVENT_STREAM)
383            )
384        })
385        .unwrap_or(false)
386    {
387        Ok(response)
388    } else {
389        Err(super::Error::InvalidContentType(content_type.clone()))
390    }
391}
392
393#[cfg(all(test, not(all(target_arch = "wasm32", target_os = "unknown"))))]
394mod tests {
395    use super::*;
396    use crate::http_client::{self, HttpClientExt};
397    use futures::StreamExt;
398    use std::collections::VecDeque;
399    use std::future::Future;
400    use std::sync::{Arc, Mutex};
401
402    /// One scripted connection: `Err` to fail the connect outright, else the
403    /// request-id header value and body chunks the connection delivers.
404    type ScriptedConnection =
405        Result<(Option<&'static str>, Vec<StreamResult<Bytes>>), http_client::Error>;
406
407    /// Scripted connection outcomes: each `send_streaming` call pops one
408    /// [`ScriptedConnection`].
409    #[derive(Clone)]
410    struct SequencedStreamingClient {
411        connections: Arc<Mutex<VecDeque<ScriptedConnection>>>,
412    }
413
414    impl SequencedStreamingClient {
415        fn new(connections: impl IntoIterator<Item = ScriptedConnection>) -> Self {
416            Self {
417                connections: Arc::new(Mutex::new(connections.into_iter().collect())),
418            }
419        }
420    }
421
422    impl HttpClientExt for SequencedStreamingClient {
423        fn send<T, U>(
424            &self,
425            _req: Request<T>,
426        ) -> impl Future<Output = http_client::Result<Response<http_client::LazyBody<U>>>>
427        + WasmCompatSend
428        + 'static
429        where
430            T: Into<Bytes> + WasmCompatSend,
431            U: From<Bytes> + WasmCompatSend + 'static,
432        {
433            std::future::ready(Err(http_client::Error::InvalidStatusCode(
434                StatusCode::NOT_IMPLEMENTED,
435            )))
436        }
437
438        fn send_multipart<U>(
439            &self,
440            _req: Request<crate::http_client::MultipartForm>,
441        ) -> impl Future<Output = http_client::Result<Response<http_client::LazyBody<U>>>>
442        + WasmCompatSend
443        + 'static
444        where
445            U: From<Bytes> + WasmCompatSend + 'static,
446        {
447            std::future::ready(Err(http_client::Error::InvalidStatusCode(
448                StatusCode::NOT_IMPLEMENTED,
449            )))
450        }
451
452        fn send_streaming<T>(
453            &self,
454            _req: Request<T>,
455        ) -> impl Future<Output = http_client::Result<http_client::StreamingResponse>> + WasmCompatSend
456        where
457            T: Into<Bytes> + WasmCompatSend,
458        {
459            let next = self
460                .connections
461                .lock()
462                .expect("scripted connections")
463                .pop_front();
464            async move {
465                let (request_id, chunks) =
466                    next.expect("a scripted connection should remain for each connect")?;
467                let boxed: BoxedStream = Box::pin(futures::stream::iter(chunks));
468                let mut builder = Response::builder()
469                    .status(StatusCode::OK)
470                    .header(http::header::CONTENT_TYPE, "text/event-stream");
471                if let Some(id) = request_id {
472                    builder = builder.header("x-request-id", id);
473                }
474                builder.body(boxed).map_err(http_client::Error::Protocol)
475            }
476        }
477    }
478
479    /// The retry number advances across reconnects, so a bounded policy
480    /// actually terminates. One arm now serves the initial connect and every
481    /// reconnect, distinguished only by the retry history it carries; were
482    /// that history dropped on the way into a reconnect, the policy would see
483    /// attempt 1 forever and `max_retries` would never be reached.
484    ///
485    /// A unit test rather than a cassette test: the behavior under test is the
486    /// state machine's own accounting, and no provider traffic can express
487    /// "the third connect attempt is refused".
488    #[tokio::test]
489    async fn a_bounded_retry_policy_stops_after_its_last_reconnect() {
490        // Four scripted failures for a policy that allows two retries: the
491        // fourth stays unused unless the numbering regresses, and the client
492        // panics past the end rather than silently looping.
493        let client = SequencedStreamingClient::new(
494            std::iter::repeat_with(|| Err(http_client::Error::StreamEnded)).take(4),
495        );
496        let req = Request::builder()
497            .uri("http://mock.invalid/stream")
498            .body(Vec::<u8>::new())
499            .expect("request should build");
500        let mut source = GenericEventSource::new(client, req);
501        source.retry_policy = ExponentialBackoff::new(
502            Duration::from_millis(1),
503            1.,
504            Some(Duration::from_millis(1)),
505            Some(2),
506        );
507        let mut source = Box::pin(source);
508
509        let mut failures = 0;
510        while let Some(item) = source.next().await {
511            assert!(item.is_err(), "every scripted connect fails");
512            failures += 1;
513        }
514
515        assert_eq!(
516            failures, 3,
517            "the initial connect plus two retries, then the policy declines"
518        );
519    }
520
521    /// Regression (rig#2265): after a mid-stream failure and reconnect, the
522    /// slot must describe the connection that is now open — a reconnect whose
523    /// response omits the header resets it to `None` instead of leaking the
524    /// first connection's id.
525    #[tokio::test]
526    async fn reconnect_replaces_request_id_slot_including_with_none() {
527        let client = SequencedStreamingClient::new([
528            Ok((
529                Some("req-first-connection"),
530                vec![
531                    Ok(Bytes::from_static(b"data: one\n\n")),
532                    Err(http_client::Error::StreamEnded),
533                ],
534            )),
535            Ok((None, vec![Ok(Bytes::from_static(b"data: two\n\n"))])),
536        ]);
537        let req = Request::builder()
538            .uri("http://mock.invalid/stream")
539            .body(Vec::<u8>::new())
540            .expect("request should build");
541        let (source, slot) =
542            GenericEventSource::new(client, req).capture_request_id("x-request-id");
543        let mut source = Box::pin(source);
544
545        let mut messages = Vec::new();
546        let mut checked_first_connection = false;
547        while let Some(item) = source.next().await {
548            if let Ok(Event::Message(message)) = item {
549                if !checked_first_connection {
550                    assert_eq!(
551                        slot.lock().expect("slot").as_deref(),
552                        Some("req-first-connection"),
553                        "the first connection's id is captured at connect"
554                    );
555                    checked_first_connection = true;
556                }
557                messages.push(message.data);
558            }
559        }
560
561        assert_eq!(messages, ["one", "two"], "both connections delivered data");
562        assert_eq!(
563            slot.lock().expect("slot").as_deref(),
564            None,
565            "the reconnect omitted the header, so the slot must not retain the \
566             first connection's id"
567        );
568    }
569}