1use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use futures_util::future::BoxFuture;
10use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
11use serde_json::{Value, json};
12use tokio_util::sync::CancellationToken;
13use yapi_types::sync::lock;
14
15use super::jsonrpc::{INTERNAL_ERROR, Incoming, McpError, classify};
16use super::oauth::is_insufficient_scope;
17use super::sign_in::McpAuth;
18use super::transport::{Event, Events, MAX_MESSAGE_BYTES};
19
20const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
21const ERROR_MESSAGE_BODY_CHARS: usize = 500;
22const RECONNECT_INITIAL_DELAY_MS: u64 = 1_000;
23const RECONNECT_MAX_DELAY_MS: u64 = 30_000;
24const RECONNECT_MAX_RETRIES: u32 = 5;
25
26pub type ProviderToken = Arc<dyn Fn(String) -> BoxFuture<'static, Option<String>> + Send + Sync>;
28
29#[derive(Clone)]
31pub enum HttpAuth {
32 OAuth(Arc<McpAuth>),
34 Provider {
36 provider: String,
38 token: Option<ProviderToken>,
40 },
41}
42
43impl HttpAuth {
44 async fn token(&self) -> Result<Option<String>, McpError> {
45 match self {
46 HttpAuth::OAuth(auth) => auth.token().await,
47 HttpAuth::Provider { provider, token } => Ok(match token {
48 Some(token) => token(provider.clone()).await,
49 None => None,
50 }),
51 }
52 }
53}
54
55#[derive(Clone, Default)]
57pub struct HttpOptions {
58 pub url: String,
60 pub headers: Vec<(String, String)>,
62 pub auth: Option<HttpAuth>,
64}
65
66#[derive(Clone)]
68pub struct HttpTransport {
69 inner: Arc<Inner>,
70}
71
72struct Inner {
73 options: HttpOptions,
74 cancel: CancellationToken,
75 session: Mutex<Option<String>>,
76 protocol_version: Mutex<Option<String>>,
77 events: Mutex<Option<Events>>,
78 started: AtomicBool,
79 closed: AtomicBool,
80 get_stream_started: AtomicBool,
81}
82
83fn network(error: reqwest::Error) -> McpError {
84 let mut message = error.to_string();
85 let mut source = std::error::Error::source(&error);
86 while let Some(cause) = source {
87 message = format!("{message}: {cause}");
88 source = cause.source();
89 }
90 McpError::Network(message)
91}
92
93fn fetch_failed(_error: reqwest::Error) -> McpError {
96 McpError::Network("fetch failed".to_owned())
97}
98
99fn content_type(response: &reqwest::Response) -> Option<String> {
100 response
101 .headers()
102 .get(reqwest::header::CONTENT_TYPE)
103 .and_then(|value| value.to_str().ok())
104 .and_then(|value| value.split(';').next())
105 .map(|value| value.trim().to_ascii_lowercase())
106}
107
108fn describe_failure(status: u16, body: &str) -> String {
109 let text = body.trim();
110 let snippet = if yapi_types::js::len(text) > ERROR_MESSAGE_BODY_CHARS {
111 format!(
112 "{}...",
113 yapi_types::js::slice(text, 0, ERROR_MESSAGE_BODY_CHARS - 3)
114 )
115 } else {
116 text.to_owned()
117 };
118 if snippet.is_empty() {
119 format!("MCP HTTP request failed with status {status}")
120 } else {
121 format!("MCP HTTP request failed with status {status}: {snippet}")
122 }
123}
124
125fn type_error(message: String) -> McpError {
127 McpError::Network(message)
128}
129
130fn byte_string(text: &str) -> Result<(), McpError> {
132 match text
133 .encode_utf16()
134 .enumerate()
135 .find(|(_, unit)| *unit > 255)
136 {
137 Some((index, unit)) => Err(type_error(format!(
138 "Cannot convert argument to a ByteString because the character at index {index} has a value of {unit} which is greater than 255."
139 ))),
140 None => Ok(()),
141 }
142}
143
144fn header_value<'v>(method: &str, value: &'v str) -> Result<&'v str, McpError> {
147 byte_string(value)?;
148 let value = value.trim_matches([' ', '\t', '\r', '\n']);
149 if value.contains(['\r', '\n', '\0']) {
150 return Err(type_error(format!(
151 "Headers.{method}: \"{value}\" is an invalid header value."
152 )));
153 }
154 Ok(value)
155}
156
157fn configured_headers(headers: &[(String, String)]) -> Result<Vec<(HeaderName, String)>, McpError> {
161 for text in headers.iter().flat_map(|(name, value)| [name, value]) {
162 byte_string(text)?;
163 }
164 headers
165 .iter()
166 .map(|(name, value)| {
167 let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
168 type_error(format!(
169 "Headers.append: \"{name}\" is an invalid header name."
170 ))
171 })?;
172 Ok((name, header_value("append", value)?.to_owned()))
173 })
174 .collect()
175}
176
177#[derive(Default)]
179struct Cursor {
180 last_event_id: Option<String>,
181 retry_ms: Option<u64>,
182 received: bool,
184}
185
186#[derive(Default)]
189struct SseParser {
190 buffer: Vec<u8>,
191 event: Option<String>,
192 data: Vec<String>,
193 data_bytes: usize,
194}
195
196struct SseEvent {
197 event: Option<String>,
198 data: String,
199}
200
201impl SseParser {
202 fn push(&mut self, chunk: &[u8], cursor: &mut Cursor) -> Result<Vec<SseEvent>, String> {
203 self.buffer.extend_from_slice(chunk);
204 let mut events = Vec::new();
205 while let Some(newline) = self.buffer.iter().position(|byte| *byte == b'\n') {
206 let line: Vec<u8> = self.buffer.drain(..=newline).collect();
207 let line = String::from_utf8_lossy(&line[..line.len() - 1]).into_owned();
208 self.line(&line, cursor, &mut events)?;
209 }
210 if self.buffer.len() > MAX_MESSAGE_BYTES {
211 return Err(format!("MCP SSE event exceeds {MAX_MESSAGE_BYTES} bytes"));
212 }
213 Ok(events)
214 }
215
216 fn finish(&mut self, cursor: &mut Cursor) -> Result<Vec<SseEvent>, String> {
217 let mut events = Vec::new();
218 if !self.buffer.is_empty() {
219 let line = String::from_utf8_lossy(&std::mem::take(&mut self.buffer)).into_owned();
220 self.line(&line, cursor, &mut events)?;
221 }
222 events.extend(self.dispatch());
223 Ok(events)
224 }
225
226 fn dispatch(&mut self) -> Option<SseEvent> {
227 let event = self.event.take();
228 if self.data.is_empty() {
229 return None;
230 }
231 self.data_bytes = 0;
232 Some(SseEvent {
233 event,
234 data: std::mem::take(&mut self.data).join("\n"),
235 })
236 }
237
238 fn line(
239 &mut self,
240 line: &str,
241 cursor: &mut Cursor,
242 events: &mut Vec<SseEvent>,
243 ) -> Result<(), String> {
244 let line = line.strip_suffix('\r').unwrap_or(line);
245 if line.is_empty() {
246 events.extend(self.dispatch());
247 return Ok(());
248 }
249 if line.starts_with(':') {
250 return Ok(());
251 }
252 let (field, value) = match line.split_once(':') {
253 Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
254 None => (line, ""),
255 };
256 match field {
257 "data" => {
258 self.data_bytes += value.len() + usize::from(!self.data.is_empty());
259 if self.data_bytes > MAX_MESSAGE_BYTES {
260 return Err(format!("MCP SSE event exceeds {MAX_MESSAGE_BYTES} bytes"));
261 }
262 self.data.push(value.to_owned());
263 }
264 "event" => self.event = Some(value.to_owned()),
265 "id" if !value.contains('\0') => cursor.last_event_id = Some(value.to_owned()),
266 "retry" if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) => {
267 cursor.retry_ms = value.parse().ok();
268 }
269 _ => {}
270 }
271 Ok(())
272 }
273}
274
275impl HttpTransport {
276 pub fn new(options: HttpOptions) -> HttpTransport {
278 HttpTransport {
279 inner: Arc::new(Inner {
280 options,
281 cancel: CancellationToken::new(),
282 session: Mutex::new(None),
283 protocol_version: Mutex::new(None),
284 events: Mutex::new(None),
285 started: AtomicBool::new(false),
286 closed: AtomicBool::new(false),
287 get_stream_started: AtomicBool::new(false),
288 }),
289 }
290 }
291
292 pub fn session_id(&self) -> Option<String> {
294 lock(&self.inner.session).clone()
295 }
296
297 pub fn start(&self, events: Events) -> Result<(), McpError> {
299 if self.inner.closed.load(Ordering::SeqCst) {
300 return Err(McpError::closed());
301 }
302 *lock(&self.inner.events) = Some(events);
303 self.inner.started.store(true, Ordering::SeqCst);
304 Ok(())
305 }
306
307 pub fn set_protocol_version(&self, version: &str) {
309 *lock(&self.inner.protocol_version) = Some(version.to_owned());
310 }
311
312 fn emit(&self, event: Event) {
313 if let Some(events) = lock(&self.inner.events).as_ref() {
314 let _ = events.send(event);
315 }
316 }
317
318 pub async fn send(&self, message: &Value) -> Result<(), McpError> {
320 if !self.inner.started.load(Ordering::SeqCst) || self.inner.closed.load(Ordering::SeqCst) {
321 return Err(McpError::closed());
322 }
323 let body = yapi_types::json::stringify(message);
324 let headers = vec![
325 (
326 reqwest::header::ACCEPT,
327 "application/json, text/event-stream".to_owned(),
328 ),
329 (reqwest::header::CONTENT_TYPE, "application/json".to_owned()),
330 ];
331 let response = self
332 .authorized(reqwest::Method::POST, Some(body), headers)
333 .await?;
334 let response = self.check(response).await?;
335 self.capture_session(&response);
336 let request_id = match classify(message) {
337 Some(Incoming::Request { id, .. }) => id.clone(),
338 _ => {
339 if message["method"] == "notifications/initialized" {
341 self.start_get_stream();
342 }
343 return Ok(());
344 }
345 };
346 let status = response.status().as_u16();
347 if status == 202 || status == 204 {
348 return Err(McpError::Http {
349 status,
350 message: format!(
351 "MCP server accepted request {} without a response",
352 message["method"].as_str().unwrap_or_default()
353 ),
354 });
355 }
356 match content_type(&response).as_deref() {
357 Some("application/json") => {
358 let bytes = response.bytes().await.map_err(network)?;
359 let body: Value = serde_json::from_slice(&bytes)
360 .map_err(|error| McpError::Other(error.to_string()))?;
361 let items = match body {
362 Value::Array(items) => items,
363 item => vec![item],
364 };
365 for item in items {
366 match classify(&item) {
367 Some(_) => self.emit(Event::Message(item)),
368 None => return Err(McpError::invalid("Invalid JSON-RPC message")),
369 }
370 }
371 Ok(())
372 }
373 Some("text/event-stream") => {
374 let transport = self.clone();
375 tokio::spawn(async move { transport.read_response(response, request_id).await });
376 Ok(())
377 }
378 other => Err(McpError::Http {
379 status,
380 message: format!(
381 "Unsupported MCP response content type: {}",
382 other.unwrap_or("missing")
383 ),
384 }),
385 }
386 }
387
388 pub async fn close(&self) {
390 if self.inner.closed.swap(true, Ordering::SeqCst) {
391 return;
392 }
393 self.inner.cancel.cancel();
394 if self.inner.started.load(Ordering::SeqCst) && self.session_id().is_some() {
395 let request = async {
396 let Ok(configured) = configured_headers(&self.inner.options.headers) else {
397 return;
398 };
399 let Ok(token) = self.token().await else {
401 return;
402 };
403 let Ok(headers) = self.headers(configured, &[], token.as_deref()) else {
404 return;
405 };
406 let _ = yapi_ai::http::client()
407 .delete(&self.inner.options.url)
408 .headers(headers)
409 .send()
410 .await;
411 };
412 let _ = tokio::time::timeout(Duration::from_secs(1), request).await;
413 }
414 self.emit(Event::Closed);
415 }
416
417 async fn token(&self) -> Result<Option<String>, McpError> {
418 match &self.inner.options.auth {
419 Some(auth) => auth.token().await,
420 None => Ok(None),
421 }
422 }
423
424 fn headers(
428 &self,
429 mut entries: Vec<(HeaderName, String)>,
430 extra: &[(HeaderName, String)],
431 token: Option<&str>,
432 ) -> Result<HeaderMap, McpError> {
433 let mut set = |name: HeaderName, value: &str| {
434 let value = header_value("set", value)?.to_owned();
435 entries.retain(|(other, _)| *other != name);
436 entries.push((name, value));
437 Ok::<_, McpError>(())
438 };
439 for (name, value) in extra {
440 set(name.clone(), value)?;
441 }
442 if let Some(session) = self.session_id() {
443 set(HeaderName::from_static("mcp-session-id"), &session)?;
444 }
445 if let Some(version) = lock(&self.inner.protocol_version).clone() {
446 set(HeaderName::from_static("mcp-protocol-version"), &version)?;
447 }
448 if let Some(token) = token.filter(|token| !token.is_empty()) {
449 set(reqwest::header::AUTHORIZATION, &format!("Bearer {token}"))?;
450 }
451 let mut headers = HeaderMap::new();
454 for (name, value) in entries {
455 let bytes: Vec<u8> = value.chars().filter_map(|c| u8::try_from(c).ok()).collect();
456 let value = HeaderValue::from_bytes(&bytes)
457 .map_err(|_| McpError::Network("fetch failed".to_owned()))?;
458 headers.append(name, value);
459 }
460 Ok(headers)
461 }
462
463 async fn authorized(
467 &self,
468 method: reqwest::Method,
469 body: Option<String>,
470 extra: Vec<(HeaderName, String)>,
471 ) -> Result<reqwest::Response, McpError> {
472 let mut attempt = 0;
473 loop {
474 let configured = configured_headers(&self.inner.options.headers)?;
475 let token = self.token().await?;
476 let mut request = yapi_ai::http::client()
477 .request(method.clone(), &self.inner.options.url)
478 .headers(self.headers(configured, &extra, token.as_deref())?);
479 if let Some(body) = &body {
480 request = request.body(body.clone());
481 }
482 let response = tokio::select! {
483 () = self.inner.cancel.cancelled() => Err(McpError::closed()),
484 response = request.send() => response.map_err(fetch_failed),
485 }?;
486 let Some(HttpAuth::OAuth(auth)) = &self.inner.options.auth else {
487 return Ok(response);
488 };
489 let challenge = response
490 .headers()
491 .get(reqwest::header::WWW_AUTHENTICATE)
492 .and_then(|value| value.to_str().ok())
493 .map(str::to_owned);
494 let status = response.status().as_u16();
495 let unauthorized =
496 status == 401 || (status == 403 && is_insufficient_scope(challenge.as_deref()));
497 if attempt > 0 || !unauthorized {
498 return Ok(response);
499 }
500 drop(response);
501 auth.on_unauthorized(challenge.as_deref(), token.as_deref())
502 .await?;
503 attempt += 1;
504 }
505 }
506
507 async fn check(&self, response: reqwest::Response) -> Result<reqwest::Response, McpError> {
508 if response.status().is_success() {
509 return Ok(response);
510 }
511 let status = response.status().as_u16();
512 let challenge = response
513 .headers()
514 .get(reqwest::header::WWW_AUTHENTICATE)
515 .and_then(|value| value.to_str().ok())
516 .map(str::to_owned);
517 let body = response.text().await.unwrap_or_default();
518 let body: String = body.chars().take(MAX_ERROR_BODY_BYTES).collect();
519 if status == 401 {
520 return Err(McpError::AuthRequired { challenge });
521 }
522 if status == 404 && self.session_id().is_some() {
523 return Err(McpError::SessionExpired);
524 }
525 Err(McpError::Http {
526 status,
527 message: describe_failure(status, &body),
528 })
529 }
530
531 fn capture_session(&self, response: &reqwest::Response) {
532 if let Some(session) = response
533 .headers()
534 .get("mcp-session-id")
535 .and_then(|value| value.to_str().ok())
536 .filter(|value| !value.is_empty())
537 {
538 *lock(&self.inner.session) = Some(session.to_owned());
539 }
540 }
541
542 async fn consume(
545 &self,
546 mut response: reqwest::Response,
547 cursor: &mut Cursor,
548 request_id: Option<&Value>,
549 ) -> Result<bool, McpError> {
550 let mut parser = SseParser::default();
551 let mut answered = false;
552 loop {
553 let chunk = tokio::select! {
554 () = self.inner.cancel.cancelled() => return Ok(answered),
555 chunk = response.chunk() => chunk.map_err(network)?,
556 };
557 let done = chunk.is_none();
558 let events = match chunk {
559 Some(bytes) => parser.push(&bytes, cursor),
560 None => parser.finish(cursor),
561 }
562 .map_err(McpError::Other)?;
563 for event in events {
564 cursor.received = true;
565 if event.data.trim().is_empty()
566 || event.event.as_deref().is_some_and(|name| name != "message")
567 {
568 continue;
569 }
570 let message = match serde_json::from_str::<Value>(&event.data) {
571 Ok(message) if classify(&message).is_some() => message,
572 Ok(_) => {
573 self.emit(Event::Error("Invalid JSON-RPC message".into()));
574 continue;
575 }
576 Err(error) => {
577 self.emit(Event::Error(error.to_string()));
578 continue;
579 }
580 };
581 if let (Some(id), Some(Incoming::Response { id: answer, .. })) =
582 (request_id, classify(&message))
583 && answer == id
584 {
585 answered = true;
586 }
587 self.emit(Event::Message(message));
588 }
589 if done {
590 return Ok(answered);
591 }
592 }
593 }
594
595 async fn read_response(&self, response: reqwest::Response, request_id: Value) {
599 let mut cursor = Cursor::default();
600 let mut stream = Some(response);
601 let mut failure: Option<McpError> = None;
602 let mut attempt = 0;
603 loop {
604 if let Some(response) = stream.take() {
605 match self.consume(response, &mut cursor, Some(&request_id)).await {
606 Ok(true) => return,
607 Ok(false) => failure = None,
608 Err(error) => failure = Some(error),
609 }
610 }
611 if self.inner.closed.load(Ordering::SeqCst) {
612 return;
613 }
614 if failure.as_ref().is_some_and(|error| !retryable(error)) {
615 break;
616 }
617 if cursor.last_event_id.is_none() || attempt >= RECONNECT_MAX_RETRIES {
618 break;
619 }
620 if cursor.received {
621 attempt = 0;
622 }
623 cursor.received = false;
624 let delay = reconnect_delay(attempt, cursor.retry_ms);
625 attempt += 1;
626 if !self.sleep(delay).await {
627 return;
628 }
629 match self.open_stream(cursor.last_event_id.clone()).await {
630 Ok(response) => stream = response,
631 Err(error) => {
632 let stop = !retryable(&error);
633 failure = Some(error);
634 if stop {
635 break;
636 }
637 }
638 }
639 }
640 if self.inner.closed.load(Ordering::SeqCst) {
641 return;
642 }
643 let reason = failure.map_or_else(
644 || "stream ended without a response".to_owned(),
645 |error| error.to_string(),
646 );
647 self.emit(Event::Message(json!({
648 "jsonrpc": "2.0",
649 "id": request_id,
650 "error": {"code": INTERNAL_ERROR, "message": format!("MCP response stream failed: {reason}")},
651 })));
652 }
653
654 fn start_get_stream(&self) {
655 if self.inner.closed.load(Ordering::SeqCst)
656 || self.inner.get_stream_started.swap(true, Ordering::SeqCst)
657 {
658 return;
659 }
660 let transport = self.clone();
661 tokio::spawn(async move { transport.run_get_stream().await });
662 }
663
664 async fn run_get_stream(&self) {
666 let mut cursor = Cursor::default();
667 let mut attempt = 0;
668 while !self.inner.closed.load(Ordering::SeqCst) {
669 match self.open_stream(cursor.last_event_id.clone()).await {
670 Ok(None) => return,
672 Ok(Some(response)) => {
673 let opened = Instant::now();
674 let result = self.consume(response, &mut cursor, None).await;
675 if let Err(error) = result {
676 if self.inner.closed.load(Ordering::SeqCst) {
677 return;
678 }
679 if !retryable(&error) {
680 self.emit(Event::Error(error.to_string()));
681 return;
682 }
683 } else if cursor.received
684 || opened.elapsed() > Duration::from_millis(RECONNECT_MAX_DELAY_MS)
685 {
686 attempt = 0;
687 }
688 }
689 Err(error) => {
690 if self.inner.closed.load(Ordering::SeqCst) {
691 return;
692 }
693 if !retryable(&error) {
694 self.emit(Event::Error(error.to_string()));
695 return;
696 }
697 }
698 }
699 cursor.received = false;
700 if attempt >= RECONNECT_MAX_RETRIES {
701 self.emit(Event::Error(
702 "MCP server-to-client stream dropped and could not be reopened".into(),
703 ));
704 return;
705 }
706 let delay = reconnect_delay(attempt, cursor.retry_ms);
707 attempt += 1;
708 if !self.sleep(delay).await {
709 return;
710 }
711 }
712 }
713
714 async fn open_stream(
716 &self,
717 last_event_id: Option<String>,
718 ) -> Result<Option<reqwest::Response>, McpError> {
719 let mut headers = vec![(reqwest::header::ACCEPT, "text/event-stream".to_owned())];
720 if let Some(id) = last_event_id {
721 headers.push((HeaderName::from_static("last-event-id"), id));
722 }
723 let response = self.authorized(reqwest::Method::GET, None, headers).await?;
724 if response.status().as_u16() == 405 {
725 return Ok(None);
726 }
727 let response = self.check(response).await?;
728 self.capture_session(&response);
729 match content_type(&response).as_deref() {
730 Some("text/event-stream") => Ok(Some(response)),
731 other => Err(McpError::Http {
732 status: response.status().as_u16(),
733 message: format!(
734 "Unsupported MCP GET response content type: {}",
735 other.unwrap_or("missing")
736 ),
737 }),
738 }
739 }
740
741 async fn sleep(&self, delay: Duration) -> bool {
743 tokio::select! {
744 () = self.inner.cancel.cancelled() => false,
745 () = tokio::time::sleep(delay) => true,
746 }
747 }
748}
749
750fn retryable(error: &McpError) -> bool {
751 match error {
752 McpError::Network(_) => true,
753 error => error
754 .status()
755 .is_some_and(|status| status == 408 || status == 429 || status >= 500),
756 }
757}
758
759fn reconnect_delay(attempt: u32, server_ms: Option<u64>) -> Duration {
760 let ms = server_ms.unwrap_or_else(|| {
761 RECONNECT_INITIAL_DELAY_MS
762 .saturating_mul(2u64.saturating_pow(attempt))
763 .min(RECONNECT_MAX_DELAY_MS)
764 });
765 Duration::from_millis(ms)
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 fn parse(chunks: &[&str]) -> (Vec<(Option<String>, String)>, Cursor) {
774 let mut parser = SseParser::default();
775 let mut cursor = Cursor::default();
776 let mut events = Vec::new();
777 for chunk in chunks {
778 events.extend(parser.push(chunk.as_bytes(), &mut cursor).unwrap());
779 }
780 events.extend(parser.finish(&mut cursor).unwrap());
781 let events = events
782 .into_iter()
783 .map(|event| (event.event, event.data))
784 .collect();
785 (events, cursor)
786 }
787
788 #[test]
789 fn sse_line_endings() {
790 let (events, cursor) = parse(&[
791 "event: message\r\ndata: {\"a\":\r",
792 "\ndata: 1}\r\nid: 7\r\n\r\n",
793 ": comment\n\ndata:tail",
794 ]);
795 assert_eq!(
796 events,
797 [
798 (Some("message".into()), "{\"a\":\n1}".into()),
799 (None, "tail".into()),
800 ]
801 );
802 assert_eq!(cursor.last_event_id.as_deref(), Some("7"));
803 }
804
805 #[test]
806 fn sse_retry_and_ids() {
807 let (events, cursor) =
808 parse(&["retry: 2500\nid: 1\n\nretry: soon\nid: a\0b\nevent: ping\n\n"]);
809 assert!(events.is_empty());
810 assert_eq!(cursor.retry_ms, Some(2500));
811 assert_eq!(cursor.last_event_id.as_deref(), Some("1"));
812 }
813}