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}