1use 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 #[project = SourceStateProjection]
45 enum SourceState {
46 Connecting {
53 #[pin]
54 response_future: ResponseFuture,
55 last_retry: Option<(usize, Duration)>,
56 },
57 Open {
59 #[pin]
60 event_stream: EventStream,
61 },
62 WaitingToRetry {
64 #[pin]
65 retry_delay: Delay,
66 current_retry: (usize, Duration),
67 },
68 Closed,
70 }
71}
72
73pub type RequestIdSlot = std::sync::Arc<std::sync::Mutex<Option<String>>>;
79
80pin_project! {
81 #[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 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 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 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 pub fn last_event_id(&self) -> Option<&str> {
161 self.last_event_id.as_deref()
162 }
163
164 pub fn close(&mut self) {
167 self.state = SourceState::Closed;
168 }
169}
170
171#[derive(Debug, Clone, Eq, PartialEq)]
173pub enum Event {
174 Open,
176 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 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 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 this.state.set(SourceState::Closed);
229 return Poll::Ready(Some(Err(err)));
230 }
231 }
232 }
233 Poll::Ready(Err(err)) => {
234 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 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 continue;
273 }
274 Poll::Ready(Some(Err(EventStreamError::Utf8(_)))) => {
275 continue;
277 }
278 Poll::Ready(None) => {
279 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 let retry_info = *current_retry;
292 match retry_delay.poll(cx) {
293 Poll::Pending => return Poll::Pending,
294 Poll::Ready(()) => {
295 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
319fn 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
339fn 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 type ScriptedConnection =
405 Result<(Option<&'static str>, Vec<StreamResult<Bytes>>), http_client::Error>;
406
407 #[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 #[tokio::test]
489 async fn a_bounded_retry_policy_stops_after_its_last_reconnect() {
490 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 #[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}