Skip to main content

alpaca_sdk/
sse.rs

1//! The shared [server-sent event](https://docs.alpaca.markets/us/docs/sse-events) transport.
2//!
3//! Alpaca pushes events on three surfaces, not one: the broker API's account,
4//! trade, journal, funding, activity, admin, IPO and system streams; the trading
5//! API's activity stream; and the market data API's corporate-actions stream.
6//! All of them are the same thing — a plain HTTP response of
7//! `text/event-stream`, read incrementally.
8//!
9//! None of the [`crate::data::live`] websocket machinery applies. There is no
10//! subscribe message, no authentication handshake, and no reconnect state
11//! machine. Resuming is the caller's job, with the cursor on the last event
12//! they handled — see [`EventStreamRequest::from_id`].
13//!
14//! Resuming is genuinely the caller's job, and it is not optional: these are
15//! push streams, so a stream that dies has *not* already delivered its events,
16//! and whatever the server emitted while nobody was connected is not replayed
17//! unless it is asked for by id. [`Event`] documents the two things that make
18//! this sharper than it looks — a dropped-message notice the caller never sees,
19//! and an end-of-stream that does not distinguish "finished" from "failed".
20//!
21//! This module was the broker client's private plumbing until the reference
22//! sweep found seven more streams outside the broker API.
23
24use chrono::{DateTime, Utc};
25use futures_util::{Stream, StreamExt as _};
26use serde::de::DeserializeOwned;
27use serde::{Deserialize, Serialize};
28
29use crate::auth::Credentials;
30use crate::error::{ApiError, Error, Result};
31
32/// The window an event stream replays before going live.
33///
34/// **Not interchangeable with the broker's
35/// `broker::GetEventsRequest`**, and the difference
36/// is not cosmetic: the five older streams bound their window by *date*, while
37/// every stream found in the reference sweep — admin actions, IPO events, system
38/// events, account activities, corporate actions — bounds it by *timestamp*. Sending a bare date to a route that parses RFC-3339 is how a
39/// filter silently stops filtering.
40///
41/// The cursor pair is a [ULID](https://github.com/ulid/spec), and `since_id`
42/// here means the ULID everywhere it is accepted — unlike the v1 broker
43/// streams, where that name belongs to a deprecated integer form.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct EventStreamRequest {
47    /// Replay events emitted at or after this time.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub since: Option<DateTime<Utc>>,
50    /// Close the connection after the last event at or before this time.
51    ///
52    /// Alpaca requires `since` whenever this is set, and rejects a future value.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub until: Option<DateTime<Utc>>,
55    /// Replay events from this ULID onwards.
56    ///
57    /// **Whether the event with this id is itself redelivered depends on the
58    /// stream**, and Alpaca's reference states it both ways: the
59    /// corporate-actions stream documents it as inclusive, while the IPO-events
60    /// stream says "the first event returned will be the one immediately after
61    /// `since_id`". One filter type serves every stream, so this cannot promise
62    /// either.
63    ///
64    /// Treat the first event after a resume as possibly-duplicate and
65    /// deduplicate on `id`: that is correct under both readings, where assuming
66    /// exclusivity drops an event on every reconnect if the stream turns out to
67    /// be inclusive.
68    ///
69    /// Mutually exclusive with `since`.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub since_id: Option<String>,
72    /// Close the connection once this ULID has been delivered, inclusive.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub until_id: Option<String>,
75}
76
77impl EventStreamRequest {
78    /// Events from `since` onwards.
79    #[must_use]
80    pub fn since(since: DateTime<Utc>) -> Self {
81        Self {
82            since: Some(since),
83            ..Self::default()
84        }
85    }
86
87    /// Events from this ULID onwards, which is how a dropped stream is resumed.
88    ///
89    /// Named `from_id` rather than `after_id` because "after" asserts an
90    /// exclusivity that only some of these streams have — see
91    /// [`EventStreamRequest::since_id`], and deduplicate on `id`.
92    #[must_use]
93    pub fn from_id(since_id: impl Into<String>) -> Self {
94        Self {
95            since_id: Some(since_id.into()),
96            ..Self::default()
97        }
98    }
99
100    /// The filter as query parameters.
101    #[must_use]
102    pub(crate) fn query(&self) -> Vec<(&'static str, String)> {
103        let mut query = Vec::new();
104        if let Some(since) = self.since {
105            query.push(("since", since.to_rfc3339()));
106        }
107        if let Some(until) = self.until {
108            query.push(("until", until.to_rfc3339()));
109        }
110        if let Some(since_id) = &self.since_id {
111            query.push(("since_id", since_id.clone()));
112        }
113        if let Some(until_id) = &self.until_id {
114            query.push(("until_id", until_id.clone()));
115        }
116        query
117    }
118}
119
120/// One event from an Alpaca event stream.
121///
122/// # What this does not carry
123///
124/// SSE comment lines are not surfaced. Alpaca uses them for two things the
125/// caller would want to know about — `: you are reading too slowly, dropped
126/// 10000 messages`, and `: internal server error` — and the parser underneath
127/// discards them before this crate sees them. Two consequences worth planning
128/// around:
129///
130/// - A slow consumer can lose events silently. The `id` values are ULIDs and a
131///   gap is not detectable from them, so a consumer that must not miss a fill
132///   should reconcile against the REST API rather than trust the stream alone.
133/// - The end of a stream is ambiguous. A server-side error followed by a clean
134///   close is indistinguishable from the stream ending because `until_id` was
135///   reached: both are `Poll::Ready(None)`. **`None` does not mean "you have
136///   everything"** — a supervisor that treats it as completion will stop
137///   resubscribing.
138///
139/// `#[non_exhaustive]` so that a comment carrier can be added without a
140/// breaking change once the parser can surface one.
141#[derive(Debug, Clone, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct Event {
144    /// The last event id the server sent.
145    ///
146    /// Per the SSE specification this persists: an event that sends no `id`
147    /// line keeps the previous one, so this is `None` only until the server has
148    /// sent a first id. That is exactly what makes it usable to resume a
149    /// dropped stream, so it is surfaced rather than discarded.
150    pub id: Option<String>,
151    /// The event's type.
152    ///
153    /// Unlike the id, this resets after every dispatch, and an event that sends
154    /// no `event` line gets the SSE specification's default of `"message"` —
155    /// so this always has a value, and that value is not always meaningful.
156    pub name: String,
157    /// The payload, as it arrived.
158    pub data: String,
159}
160
161impl Event {
162    /// Deserializes the payload.
163    ///
164    /// Most of these streams have no captured payloads to model from, so the
165    /// type is the caller's to choose — [`serde_json::Value`] to look first, a
166    /// struct once the shape is known.
167    ///
168    /// # Errors
169    /// Returns [`Error::Decode`] if the payload is not valid JSON for `T`.
170    pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
171        serde_json::from_str(&self.data).map_err(|source| Error::Decode {
172            path: "event stream".to_owned(),
173            body: self.data.clone(),
174            source,
175        })
176    }
177}
178
179impl From<eventsource_stream::Event> for Event {
180    fn from(event: eventsource_stream::Event) -> Self {
181        Self {
182            // The parser reports "no id yet" as the empty string, which is not
183            // the same thing as an id of "". Once the server has sent one it
184            // persists, so this is only ever None at the head of a stream.
185            id: (!event.id.is_empty()).then_some(event.id),
186            name: event.event,
187            data: event.data,
188        }
189    }
190}
191
192/// Translates a stream-level failure into this crate's error type.
193///
194/// All three arms are [`Error::Stream`], which is the variant the websocket
195/// paths use too. The distinction the caller usually wants — did the transport
196/// drop, or did the server send something unparseable — is in the message rather
197/// than in the type, because it does not change what a caller can do about it.
198pub(crate) fn stream_error(error: &eventsource_stream::EventStreamError<reqwest::Error>) -> Error {
199    match error {
200        eventsource_stream::EventStreamError::Transport(source) => {
201            Error::Stream(format!("event stream failed: {source}"))
202        }
203        eventsource_stream::EventStreamError::Utf8(source) => {
204            Error::Stream(format!("event stream was not valid utf-8: {source}"))
205        }
206        eventsource_stream::EventStreamError::Parser(source) => {
207            Error::Stream(format!("event stream was malformed: {source}"))
208        }
209    }
210}
211
212/// Builds the HTTP client an event stream is read through.
213///
214/// Separate from [`crate::rest::RestClient`] because that one decodes a whole
215/// body and these are read incrementally. Redirects are refused, as they are
216/// there.
217///
218/// # No timeout
219///
220/// [`RestConfig::timeout`] is deliberately *not* applied here. reqwest's
221/// `ClientBuilder::timeout` is a total deadline on the whole request, "until
222/// the response body has finished" — and the body of a `text/event-stream`
223/// never finishes. Applying it would give every subscription a fixed lifespan:
224/// events until the deadline, then a timeout error, forever — and a supervisor
225/// that reconnects on error would do so on that same clock, dropping whatever
226/// arrived in the gap each time.
227///
228/// A stall detector belongs on the *reads*, not on the stream, so if one is
229/// wanted later it is `read_timeout`, which resets per chunk.
230///
231/// # Errors
232/// Returns an error if the credentials cannot be encoded as headers.
233pub(crate) fn streaming_client(credentials: &Credentials) -> Result<reqwest::Client> {
234    let mut headers = reqwest::header::HeaderMap::new();
235    credentials.apply(&mut headers)?;
236    headers.insert(
237        reqwest::header::USER_AGENT,
238        reqwest::header::HeaderValue::from_static(crate::config::user_agent()),
239    );
240
241    reqwest::Client::builder()
242        .default_headers(headers)
243        // No event stream redirects, so following one has no upside and some
244        // downside: reqwest strips `Authorization` on a cross-host hop but not
245        // the `APCA-API-*` headers the trading and data clients send, and this
246        // is a long-lived credentialed connection. The one route that does
247        // redirect — the broker document download — has its own client below.
248        .redirect(reqwest::redirect::Policy::none())
249        .build()
250        .map_err(Error::transport)
251}
252
253/// Builds the HTTP client the broker document download uses.
254///
255/// Separate from [`streaming_client`] for one reason: this one *does* apply
256/// [`RestConfig::timeout`]. A document download is an ordinary request/response
257/// call with a body that ends, so a total deadline is exactly the right shape
258/// for it — the argument for withholding one from an event stream does not
259/// transfer, and folding the two together silently dropped the caller's
260/// deadline from the two download routes.
261///
262/// Redirects are followed: the route answers `301` to a presigned storage URL.
263/// Safe because the broker client converts its credentials to basic auth, which
264/// reqwest strips on a cross-host hop.
265///
266/// # Errors
267/// Returns an error if the credentials cannot be encoded as headers.
268#[cfg(feature = "broker")]
269pub(crate) fn download_client(
270    credentials: &Credentials,
271    timeout: Option<std::time::Duration>,
272) -> Result<reqwest::Client> {
273    let mut headers = reqwest::header::HeaderMap::new();
274    credentials.apply(&mut headers)?;
275    headers.insert(
276        reqwest::header::USER_AGENT,
277        reqwest::header::HeaderValue::from_static(crate::config::user_agent()),
278    );
279
280    let mut builder = reqwest::Client::builder()
281        .default_headers(headers)
282        .redirect(reqwest::redirect::Policy::limited(10));
283    if let Some(timeout) = timeout {
284        builder = builder.timeout(timeout);
285    }
286    builder.build().map_err(Error::transport)
287}
288
289/// Opens an event stream at `url`.
290///
291/// The subscription itself is awaited so a rejected one — bad credentials, a
292/// filter the server dislikes — surfaces as an error here rather than as a
293/// single item on an otherwise empty stream.
294///
295/// `path` is only used to label errors; `url` is the absolute address, because
296/// Alpaca versions these endpoints individually and the version is therefore
297/// not the client's.
298///
299/// # Errors
300/// Propagates transport failures and any non-success status the server answers
301/// the subscription with.
302pub(crate) async fn subscribe(
303    http: &reqwest::Client,
304    url: &str,
305    path: &str,
306    query: &[(&'static str, String)],
307) -> Result<impl Stream<Item = Result<Event>> + use<>> {
308    use eventsource_stream::Eventsource as _;
309
310    let mut request = http
311        .get(url)
312        // The four headers Alpaca's event streams expect on a subscription.
313        .header(reqwest::header::CONNECTION, "keep-alive")
314        .header(reqwest::header::CACHE_CONTROL, "no-cache")
315        .header(reqwest::header::CONTENT_TYPE, "text/event-stream")
316        .header(reqwest::header::ACCEPT, "text/event-stream");
317    if !query.is_empty() {
318        request = request.query(query);
319    }
320
321    let response = request.send().await.map_err(Error::transport)?;
322    let status = response.status();
323    if !status.is_success() {
324        let body = response.text().await.unwrap_or_default();
325        return Err(Error::Api(ApiError::from_body(status.as_u16(), path, body)));
326    }
327
328    // Nothing is retried past this point.
329    Ok(response
330        .bytes_stream()
331        .eventsource()
332        .map(|event| match event {
333            Ok(event) => Ok(Event::from(event)),
334            Err(error) => Err(stream_error(&error)),
335        }))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn no_id_yet_is_none_rather_than_an_empty_string() {
344        // The parser signals "the server has not sent an id" with an empty
345        // string. An id of "" and no id at all are different things, and only
346        // the second one is safe to resume from.
347        let event = Event::from(eventsource_stream::Event {
348            event: "message".to_owned(),
349            data: "{}".to_owned(),
350            id: String::new(),
351            retry: None,
352        });
353
354        assert_eq!(event.id, None);
355        assert_eq!(event.name, "message");
356    }
357
358    #[test]
359    fn the_timestamp_window_renders_as_rfc_3339_rather_than_a_date() {
360        // The whole reason this type exists beside the broker's date-bounded
361        // filter: these streams parse RFC-3339, and a bare date sent to them is
362        // a filter that quietly stops filtering.
363        let filter = EventStreamRequest::since("2026-03-20T12:24:58Z".parse().unwrap());
364        let query = filter.query();
365
366        assert_eq!(query.len(), 1);
367        assert_eq!(query[0].0, "since");
368        assert!(query[0].1.starts_with("2026-03-20T12:24:58"));
369    }
370
371    #[test]
372    fn an_empty_filter_sends_nothing() {
373        assert!(EventStreamRequest::default().query().is_empty());
374    }
375
376    /// `path` labels the error, not `url` — the two differ deliberately (see
377    /// [`subscribe`]'s doc comment), so a wrong implementation that reached for
378    /// the request's own path instead would not be caught by asserting they
379    /// happen to match.
380    #[tokio::test]
381    async fn a_rejected_subscription_is_labeled_with_the_given_path() {
382        use wiremock::matchers::{method, path as path_matcher};
383        use wiremock::{Mock, MockServer, ResponseTemplate};
384
385        let server = MockServer::start().await;
386        Mock::given(method("GET"))
387            .and(path_matcher("/v1/events/status"))
388            .respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
389            .mount(&server)
390            .await;
391
392        let http = reqwest::Client::new();
393        let url = format!("{}/v1/events/status", server.uri());
394        // The `Ok` side is a `Stream`, which is not `Debug`, so this cannot be
395        // `unwrap_err()`.
396        match subscribe(&http, &url, "/status", &[]).await {
397            Err(Error::Api(api)) => {
398                assert_eq!(api.status, 403);
399                assert_eq!(api.path, "/status");
400                assert_eq!(api.message, "forbidden");
401            }
402            Err(other) => panic!("expected Api, got {other:?}"),
403            Ok(_) => panic!("expected the rejected subscription to error"),
404        }
405    }
406
407    #[test]
408    fn the_payload_deserializes_into_a_caller_chosen_type() {
409        let event = Event {
410            id: Some("1".to_owned()),
411            name: "message".to_owned(),
412            data: r#"{"status_to":"ACTIVE"}"#.to_owned(),
413        };
414
415        let value: serde_json::Value = event.json().unwrap();
416        assert_eq!(value["status_to"], "ACTIVE");
417
418        // And a mismatch is a decode error carrying the payload, not a panic.
419        let error = event.json::<Vec<u8>>().unwrap_err();
420        assert!(matches!(error, Error::Decode { .. }));
421    }
422}