everruns-sdk 0.1.6

Rust SDK for Everruns API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
//! Server-Sent Events (SSE) streaming with automatic reconnection.
//!
//! Implements robust SSE streaming with:
//! - Automatic reconnection on disconnect
//! - Server retry hints
//! - Graceful handling of `disconnecting` events
//! - Exponential backoff for unexpected disconnections
//! - Resume from last event ID via `since_id`

use crate::client::Everruns;
use crate::error::{Error, Result};
use crate::models::Event;
use futures::stream::Stream;
use serde::Deserialize;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::time::{Sleep, sleep};

/// Maximum retry delay for exponential backoff
const MAX_RETRY_MS: u64 = 30_000;
/// Initial retry delay for exponential backoff
const INITIAL_BACKOFF_MS: u64 = 1000;
/// Read timeout for detecting stalled/half-open SSE connections (seconds).
/// The server sends heartbeat comments every 30s. Missing a heartbeat
/// indicates a stalled connection, so 45s reliably detects them.
pub const READ_TIMEOUT_SECS: u64 = 45;
/// Default idle timeout for detecting half-open connections at the poll level
/// (seconds). reqwest's read_timeout is ineffective on already-streaming SSE
/// responses, so EventStream races this timer against inner.poll_next().
/// 45s = 1.5× the server's 30s heartbeat interval.
pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 45;

/// Options for SSE streaming
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct StreamOptions {
    /// Positive type filter: only return events matching these types
    pub types: Vec<String>,
    /// Event types to exclude from the stream (applied after `types` filter)
    pub exclude: Vec<String>,
    /// Resume from a specific event ID
    pub since_id: Option<String>,
    /// Maximum number of reconnection attempts (None = unlimited)
    pub max_retries: Option<u32>,
    /// Idle timeout for detecting half-open connections at the poll level.
    /// When no events are yielded within this duration, the stream reconnects.
    /// Default: 45s (1.5× the server's 30s heartbeat interval).
    pub idle_timeout: Duration,
}

impl Default for StreamOptions {
    fn default() -> Self {
        Self {
            types: vec![],
            exclude: vec![],
            since_id: None,
            max_retries: None,
            idle_timeout: Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS),
        }
    }
}

impl StreamOptions {
    /// Create new empty stream options
    pub fn new() -> Self {
        Self::default()
    }

    /// Create options that exclude delta events (for reduced bandwidth)
    pub fn exclude_deltas() -> Self {
        Self {
            exclude: vec![
                "output.message.delta".to_string(),
                "reason.thinking.delta".to_string(),
            ],
            ..Self::default()
        }
    }

    /// Set the positive type filter
    pub fn with_types(mut self, types: Vec<String>) -> Self {
        self.types = types;
        self
    }

    /// Set the event types to exclude
    pub fn with_exclude(mut self, exclude: Vec<String>) -> Self {
        self.exclude = exclude;
        self
    }

    /// Set the since_id for resuming a stream
    pub fn with_since_id(mut self, since_id: impl Into<String>) -> Self {
        self.since_id = Some(since_id.into());
        self
    }

    /// Set maximum retry attempts
    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
        self.max_retries = Some(max_retries);
        self
    }

    /// Set idle timeout for detecting half-open connections.
    ///
    /// When no events are yielded within this duration, the stream assumes
    /// the connection is stale and reconnects. This catches half-open TCP
    /// connections that reqwest's read_timeout misses on streaming responses.
    ///
    /// Default: 45s (1.5× the server's 30s heartbeat interval).
    pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
        self.idle_timeout = timeout;
        self
    }
}

/// Data from a disconnecting event
#[derive(Debug, Clone, serde::Serialize, Deserialize)]
pub struct DisconnectingData {
    /// Reason for disconnection (e.g., "connection_cycle")
    pub reason: String,
    /// Suggested retry delay in milliseconds
    pub retry_ms: u64,
}

/// A stream of SSE events from a session with automatic reconnection.
///
/// This stream handles:
/// - Graceful `disconnecting` events from the server
/// - Unexpected connection drops with exponential backoff
/// - Server retry hints
/// - Automatic resume using `since_id`
///
/// # Example
///
/// ```no_run
/// use futures::StreamExt;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = everruns_sdk::Everruns::new("your_api_key")?;
/// let mut stream = client.events().stream("session_id");
///
/// while let Some(result) = stream.next().await {
///     match result {
///         Ok(event) => println!("Event: {:?}", event.event_type),
///         Err(e) => eprintln!("Error: {}", e),
///     }
/// }
/// # Ok(())
/// # }
/// ```
pub struct EventStream {
    client: Everruns,
    session_id: String,
    options: StreamOptions,
    inner: Option<Pin<Box<dyn Stream<Item = Result<Event>> + Send>>>,
    last_event_id: Option<String>,
    /// Server-provided retry hint in milliseconds
    server_retry_ms: Option<u64>,
    /// Current backoff delay for unexpected disconnections
    current_backoff_ms: u64,
    /// Number of consecutive reconnection attempts
    retry_count: u32,
    /// Whether the stream should continue reconnecting
    should_reconnect: bool,
    /// Whether we received a graceful disconnect
    graceful_disconnect: bool,
    /// Pending delay before reconnection (non-blocking)
    delay_future: Option<Pin<Box<Sleep>>>,
    /// Shared flag set by connect() when `connected` event is received.
    /// Checked by poll_next() to reset backoff — proves connection is healthy.
    connected_signal: Arc<AtomicBool>,
    /// Shared reqwest client reused across reconnections for connection pooling
    sse_http_client: reqwest::Client,
    /// Poll-level idle timer. Fires when no events are yielded within
    /// `idle_timeout`, triggering reconnection. Catches half-open TCP
    /// connections that reqwest's read_timeout misses on streaming SSE.
    idle_deadline: Option<Pin<Box<Sleep>>>,
    /// Duration before idle_deadline fires
    idle_timeout: Duration,
}

impl EventStream {
    pub(crate) fn new(client: Everruns, session_id: String, options: StreamOptions) -> Self {
        // Dedicated SSE client: no overall timeout (streams run for hours),
        // reused across reconnections for connection pool / TCP reuse.
        // read_timeout is kept as a secondary safety net, but the primary
        // stall detection is the poll-level idle_deadline (see poll_next).
        let sse_http_client = reqwest::Client::builder()
            .read_timeout(Duration::from_secs(READ_TIMEOUT_SECS))
            .build()
            .unwrap_or_else(|_| reqwest::Client::new());

        let idle_timeout = options.idle_timeout;

        Self {
            client,
            session_id,
            options,
            inner: None,
            last_event_id: None,
            server_retry_ms: None,
            current_backoff_ms: INITIAL_BACKOFF_MS,
            retry_count: 0,
            should_reconnect: true,
            graceful_disconnect: false,
            delay_future: None,
            connected_signal: Arc::new(AtomicBool::new(false)),
            sse_http_client,
            idle_deadline: None,
            idle_timeout,
        }
    }

    /// Get the last received event ID (for resuming)
    pub fn last_event_id(&self) -> Option<&str> {
        self.last_event_id.as_deref()
    }

    /// Stop the stream and prevent further reconnection attempts
    pub fn stop(&mut self) {
        self.should_reconnect = false;
        self.inner = None;
        self.delay_future = None;
        self.idle_deadline = None;
    }

    /// Get the current retry count
    pub fn retry_count(&self) -> u32 {
        self.retry_count
    }

    fn connect(&mut self) -> Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
        let client = self.client.clone();
        let session_id = self.session_id.clone();
        let since_id = self
            .last_event_id
            .clone()
            .or_else(|| self.options.since_id.clone());
        let types: Vec<String> = self.options.types.clone();
        let exclude: Vec<String> = self.options.exclude.clone();
        let connected_signal = self.connected_signal.clone();
        let http_client = self.sse_http_client.clone();

        Box::pin(async_stream::try_stream! {
            use reqwest_eventsource::{Event as SseEvent, RequestBuilderExt};
            use futures::StreamExt;

            let types_refs: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
            let exclude_refs: Vec<&str> = exclude.iter().map(|s| s.as_str()).collect();
            let url = client.sse_url(&session_id, since_id.as_deref(), &types_refs, &exclude_refs);

            tracing::debug!("Connecting to SSE: {}", url);

            let mut es = http_client
                .get(url.clone())
                .header("Authorization", client.auth_header())
                .header("Accept", "text/event-stream")
                .header("Cache-Control", "no-cache")
                .eventsource()
                .map_err(|e| Error::Sse(e.to_string()))?;

            while let Some(event) = es.next().await {
                match event {
                    Ok(SseEvent::Open) => {
                        tracing::debug!("SSE connection opened");
                    }
                    Ok(SseEvent::Message(msg)) => {
                        // Handle special lifecycle events
                        if msg.event == "connected" {
                            tracing::debug!("SSE connected event received");
                            // Signal outer EventStream to reset backoff —
                            // proves the connection is healthy.
                            connected_signal.store(true, Ordering::Release);
                            continue;
                        }

                        if msg.event == "disconnecting" {
                            // Parse disconnecting data for retry hint
                            if let Ok(data) = serde_json::from_str::<DisconnectingData>(&msg.data) {
                                tracing::debug!(
                                    "SSE disconnecting: reason={}, retry_ms={}",
                                    data.reason,
                                    data.retry_ms
                                );
                                Err(Error::GracefulDisconnect {
                                    reason: data.reason,
                                    retry_ms: data.retry_ms,
                                })?;
                            } else {
                                tracing::debug!("SSE disconnecting event received (no data)");
                                Err(Error::GracefulDisconnect {
                                    reason: "unknown".to_string(),
                                    retry_ms: 100,
                                })?;
                            }
                        }

                        // Parse and yield regular events
                        if let Ok(event) = serde_json::from_str::<Event>(&msg.data) {
                            yield event;
                        } else {
                            tracing::debug!("Skipping non-event message: {}", msg.event);
                        }
                    }
                    Err(reqwest_eventsource::Error::StreamEnded) => {
                        tracing::debug!("SSE stream ended");
                        break;
                    }
                    Err(e) => {
                        tracing::warn!("SSE error: {}", e);
                        Err(Error::Sse(e.to_string()))?;
                    }
                }
            }
        })
    }

    fn get_retry_delay(&self) -> Duration {
        if self.graceful_disconnect {
            // Use server hint for graceful disconnect, or short default
            Duration::from_millis(self.server_retry_ms.unwrap_or(100))
        } else {
            // Use exponential backoff for unexpected disconnects
            Duration::from_millis(self.current_backoff_ms)
        }
    }

    fn update_backoff(&mut self) {
        if !self.graceful_disconnect {
            // Exponential backoff for unexpected disconnections
            self.current_backoff_ms = (self.current_backoff_ms * 2).min(MAX_RETRY_MS);
        }
    }

    fn reset_backoff(&mut self) {
        self.current_backoff_ms = INITIAL_BACKOFF_MS;
        self.retry_count = 0;
    }

    fn should_retry(&self) -> bool {
        if !self.should_reconnect {
            return false;
        }
        match self.options.max_retries {
            Some(max) => self.retry_count < max,
            None => true,
        }
    }

    fn schedule_reconnect(&mut self, delay: Duration) {
        self.delay_future = Some(Box::pin(sleep(delay)));
    }
}

impl Stream for EventStream {
    type Item = Result<Event>;

    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        loop {
            // Check if we're waiting for a delay before reconnecting
            if let Some(ref mut delay) = self.delay_future {
                match Pin::new(delay).poll(cx) {
                    Poll::Ready(()) => {
                        // Delay completed, clear it and reconnect
                        self.delay_future = None;
                        self.graceful_disconnect = false;
                    }
                    Poll::Pending => {
                        // Still waiting for delay
                        return Poll::Pending;
                    }
                }
            }

            // Check if connect() received a `connected` event — proves
            // the connection is healthy, so reset backoff/retry state.
            if self.connected_signal.swap(false, Ordering::Acquire) {
                self.reset_backoff();
            }

            if self.inner.is_none() {
                if !self.should_reconnect {
                    return Poll::Ready(None);
                }
                self.inner = Some(self.connect());
                // Start idle timer when a new connection is established
                self.idle_deadline = Some(Box::pin(sleep(self.idle_timeout)));
            }

            // Check idle timeout — detects half-open TCP connections where
            // reqwest's read_timeout is ineffective on streaming SSE.
            if let Some(ref mut idle) = self.idle_deadline
                && Pin::new(idle).poll(cx).is_ready()
            {
                tracing::warn!(
                    timeout_secs = self.idle_timeout.as_secs(),
                    "SSE idle timeout, reconnecting"
                );
                self.inner = None;
                self.idle_deadline = None;
                if self.should_retry() {
                    self.retry_count += 1;
                    let delay = self.get_retry_delay();
                    self.update_backoff();
                    self.schedule_reconnect(delay);
                    continue;
                }
                return Poll::Ready(None);
            }

            let inner = self.inner.as_mut().unwrap();
            match Pin::new(inner).poll_next(cx) {
                Poll::Ready(Some(Ok(event))) => {
                    // Successfully received an event - reset backoff and idle timer
                    self.reset_backoff();
                    self.last_event_id = Some(event.id.clone());
                    self.idle_deadline = Some(Box::pin(sleep(self.idle_timeout)));
                    return Poll::Ready(Some(Ok(event)));
                }
                Poll::Ready(Some(Err(e))) => {
                    // Check if this is a graceful disconnect
                    if let Error::GracefulDisconnect { retry_ms, .. } = &e {
                        self.server_retry_ms = Some(*retry_ms);
                        self.graceful_disconnect = true;
                        self.inner = None;
                        self.idle_deadline = None;

                        // Graceful disconnects are planned server behavior (connection
                        // cycling), not errors. Don't increment retry_count so they
                        // never exhaust max_retries.
                        if self.should_reconnect {
                            let delay = self.get_retry_delay();
                            tracing::debug!("Graceful reconnect in {:?}", delay);
                            self.schedule_reconnect(delay);
                            continue;
                        } else {
                            return Poll::Ready(None);
                        }
                    }

                    // Unexpected error - use exponential backoff
                    self.graceful_disconnect = false;
                    self.inner = None;
                    self.idle_deadline = None;

                    if self.should_retry() {
                        self.retry_count += 1;
                        let delay = self.get_retry_delay();
                        self.update_backoff();
                        tracing::debug!(
                            "Reconnecting after error in {:?} (attempt {})",
                            delay,
                            self.retry_count
                        );
                        self.schedule_reconnect(delay);
                        continue;
                    } else {
                        return Poll::Ready(Some(Err(e)));
                    }
                }
                Poll::Ready(None) => {
                    // Stream ended - always retry to handle read timeout case
                    self.inner = None;
                    self.idle_deadline = None;

                    if self.should_retry() {
                        self.retry_count += 1;
                        let delay = self.get_retry_delay();
                        self.update_backoff();
                        tracing::debug!(
                            "Stream ended, reconnecting in {:?} (attempt {})",
                            delay,
                            self.retry_count
                        );
                        self.schedule_reconnect(delay);
                        continue;
                    }

                    return Poll::Ready(None);
                }
                Poll::Pending => return Poll::Pending,
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_stream_options_default() {
        let opts = StreamOptions::default();
        assert!(opts.exclude.is_empty());
        assert!(opts.since_id.is_none());
        assert!(opts.max_retries.is_none());
    }

    #[test]
    fn test_stream_options_exclude_deltas() {
        let opts = StreamOptions::exclude_deltas();
        assert!(opts.exclude.contains(&"output.message.delta".to_string()));
        assert!(opts.exclude.contains(&"reason.thinking.delta".to_string()));
    }

    #[test]
    fn test_stream_options_builder() {
        let opts = StreamOptions::default()
            .with_since_id("event_123")
            .with_max_retries(5)
            .with_idle_timeout(Duration::from_secs(60));
        assert_eq!(opts.since_id, Some("event_123".to_string()));
        assert_eq!(opts.max_retries, Some(5));
        assert_eq!(opts.idle_timeout, Duration::from_secs(60));
    }

    #[test]
    fn test_stream_options_default_idle_timeout() {
        let opts = StreamOptions::default();
        assert_eq!(
            opts.idle_timeout,
            Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS)
        );
    }

    #[test]
    fn test_disconnecting_data_parse() {
        let json = r#"{"reason":"connection_cycle","retry_ms":100}"#;
        let data: DisconnectingData = serde_json::from_str(json).unwrap();
        assert_eq!(data.reason, "connection_cycle");
        assert_eq!(data.retry_ms, 100);
    }
}