Skip to main content

oanda_rs/streaming/
mod.rs

1//! Self-managing streaming connections for the pricing and transaction
2//! streams.
3//!
4//! The streams returned by
5//! [`Client::pricing_stream`](crate::Client::pricing_stream) and
6//! [`Client::transaction_stream`](crate::Client::transaction_stream) manage
7//! their own connection state:
8//!
9//! - OANDA sends a heartbeat line every 5 seconds; if nothing arrives
10//!   within the configured heartbeat timeout, the connection is considered
11//!   stale and replaced.
12//! - Reconnects use capped exponential backoff with jitter (default 1s
13//!   doubling up to 5 minutes), so an extended outage — e.g. OANDA's
14//!   weekend maintenance — is retried gently instead of flooding. The
15//!   backoff only resets after a connection has stayed healthy for a
16//!   stability period, and every attempt is gated by the client's shared
17//!   2-connections-per-second limiter.
18//! - The transaction stream tracks the last transaction ID it delivered
19//!   and back-fills the gap via `GET .../transactions/sinceid` after every
20//!   reconnect (no silent data loss); the pricing stream reconnects with
21//!   `snapshot=true` so fresh prices arrive immediately.
22//!
23//! Heartbeats are yielded to the caller (useful as a liveness signal);
24//! reconnection is otherwise invisible. Fatal errors (HTTP 4xx on
25//! reconnect) end the stream with a final `Err` item.
26
27mod json_lines;
28mod managed;
29
30use std::pin::Pin;
31use std::task::{Context, Poll};
32use std::time::Duration;
33
34use futures_core::Stream;
35use futures_util::StreamExt;
36use reqwest::{Method, Url};
37
38pub use managed::StreamStats;
39
40use crate::client::Client;
41use crate::error::Error;
42use crate::models::transaction::TransactionStreamItem;
43use crate::models::{AccountId, PriceStreamItem};
44pub(crate) use managed::StreamKind;
45use managed::{ByteStream, ManagedStream};
46
47/// Reconnection policy of a managed stream.
48#[derive(Debug, Clone)]
49pub(crate) struct StreamConfig {
50    pub(crate) auto_reconnect: bool,
51    pub(crate) heartbeat_timeout: Duration,
52    pub(crate) backoff_initial: Duration,
53    pub(crate) backoff_max: Duration,
54    pub(crate) backoff_reset_after: Duration,
55    pub(crate) max_reconnect_attempts: Option<u32>,
56}
57
58impl Default for StreamConfig {
59    fn default() -> Self {
60        StreamConfig {
61            auto_reconnect: true,
62            heartbeat_timeout: Duration::from_secs(10),
63            backoff_initial: Duration::from_secs(1),
64            backoff_max: Duration::from_secs(300),
65            backoff_reset_after: Duration::from_secs(60),
66            max_reconnect_attempts: None,
67        }
68    }
69}
70
71/// Opens a streaming connection: waits for a connection-limiter slot,
72/// sends the request, and verifies the response status.
73pub(crate) async fn open_stream(client: Client, url: Url) -> Result<ByteStream, Error> {
74    client.acquire_connection_slot().await;
75    let response = client.request(Method::GET, url).send().await?;
76    if !response.status().is_success() {
77        return Err(crate::transport::error_from_response(response).await);
78    }
79    Ok(response.bytes_stream().boxed())
80}
81
82pub(crate) struct PricingKind {
83    pub(crate) client: Client,
84    pub(crate) account_id: AccountId,
85    pub(crate) instruments: String,
86    pub(crate) snapshot: Option<bool>,
87}
88
89impl PricingKind {
90    fn url(&self, reconnect: bool) -> Url {
91        let mut url =
92            self.client
93                .stream_url(&["accounts", self.account_id.as_str(), "pricing", "stream"]);
94        {
95            let mut query = url.query_pairs_mut();
96            query.append_pair("instruments", &self.instruments);
97            // Reconnects always request a snapshot so consumers get fresh
98            // prices immediately after a gap.
99            let snapshot = if reconnect { Some(true) } else { self.snapshot };
100            if let Some(snapshot) = snapshot {
101                query.append_pair("snapshot", if snapshot { "true" } else { "false" });
102            }
103        }
104        url
105    }
106}
107
108impl StreamKind for PricingKind {
109    type Item = PriceStreamItem;
110
111    fn connect(
112        &mut self,
113        reconnect: bool,
114    ) -> futures_core::future::BoxFuture<'static, Result<ByteStream, Error>> {
115        let client = self.client.clone();
116        let url = self.url(reconnect);
117        Box::pin(open_stream(client, url))
118    }
119}
120
121/// A self-managing pricing stream; see the [module docs](self) for the
122/// reconnection behaviour. Yields [`PriceStreamItem`]s.
123pub struct PricingStream {
124    inner: ManagedStream<PricingKind>,
125}
126
127impl PricingStream {
128    pub(crate) fn new(kind: PricingKind, config: StreamConfig, initial: ByteStream) -> Self {
129        PricingStream {
130            inner: ManagedStream::new(kind, config, initial),
131        }
132    }
133
134    /// A snapshot of the stream's connection statistics.
135    pub fn stats(&self) -> StreamStats {
136        self.inner.stats()
137    }
138}
139
140impl Stream for PricingStream {
141    type Item = Result<PriceStreamItem, Error>;
142
143    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
144        Pin::new(&mut self.inner).poll_next(cx)
145    }
146}
147
148impl std::fmt::Debug for PricingStream {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("PricingStream")
151            .field("stats", &self.stats())
152            .finish_non_exhaustive()
153    }
154}
155
156pub(crate) struct TransactionKind {
157    pub(crate) client: Client,
158    pub(crate) account_id: AccountId,
159    /// The numeric ID of the last transaction yielded, used for reconnect
160    /// back-fill and deduplication.
161    pub(crate) last_seen: Option<u64>,
162}
163
164impl StreamKind for TransactionKind {
165    type Item = TransactionStreamItem;
166
167    fn connect(
168        &mut self,
169        _reconnect: bool,
170    ) -> futures_core::future::BoxFuture<'static, Result<ByteStream, Error>> {
171        let client = self.client.clone();
172        let url = self.client.stream_url(&[
173            "accounts",
174            self.account_id.as_str(),
175            "transactions",
176            "stream",
177        ]);
178        Box::pin(open_stream(client, url))
179    }
180
181    fn filter(&mut self, item: &TransactionStreamItem) -> bool {
182        if let TransactionStreamItem::Transaction(tx) = item {
183            if let Some(id) = tx.id().and_then(|id| id.as_str().parse::<u64>().ok()) {
184                if self.last_seen.is_some_and(|seen| id <= seen) {
185                    return false; // already delivered (backfill overlap)
186                }
187                self.last_seen = Some(id);
188            }
189        }
190        true
191    }
192
193    fn backfill(
194        &mut self,
195    ) -> Option<futures_core::future::BoxFuture<'static, Result<Vec<TransactionStreamItem>, Error>>>
196    {
197        let last_seen = self.last_seen?;
198        let client = self.client.clone();
199        let account_id = self.account_id.clone();
200        Some(Box::pin(async move {
201            let response = client
202                .transactions_since_id(account_id, last_seen.to_string())
203                .await?;
204            Ok(response
205                .transactions
206                .into_iter()
207                .map(TransactionStreamItem::Transaction)
208                .collect())
209        }))
210    }
211}
212
213/// A self-managing transaction stream; see the [module docs](self) for the
214/// reconnection and back-fill behaviour. Yields
215/// [`TransactionStreamItem`]s.
216pub struct TransactionStream {
217    inner: ManagedStream<TransactionKind>,
218}
219
220impl TransactionStream {
221    pub(crate) fn new(kind: TransactionKind, config: StreamConfig, initial: ByteStream) -> Self {
222        TransactionStream {
223            inner: ManagedStream::new(kind, config, initial),
224        }
225    }
226
227    /// A snapshot of the stream's connection statistics.
228    pub fn stats(&self) -> StreamStats {
229        self.inner.stats()
230    }
231}
232
233impl Stream for TransactionStream {
234    type Item = Result<TransactionStreamItem, Error>;
235
236    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
237        Pin::new(&mut self.inner).poll_next(cx)
238    }
239}
240
241impl std::fmt::Debug for TransactionStream {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct("TransactionStream")
244            .field("stats", &self.stats())
245            .finish_non_exhaustive()
246    }
247}
248
249/// Generates the shared reconnection-policy setters on stream request
250/// builders.
251macro_rules! stream_config_setters {
252    () => {
253        /// Enables or disables automatic reconnection (enabled by
254        /// default). When disabled, the stream ends on the first
255        /// connection problem.
256        pub fn auto_reconnect(mut self, enabled: bool) -> Self {
257            self.config.auto_reconnect = enabled;
258            self
259        }
260
261        /// How long without any line (heartbeats arrive every 5s) before
262        /// the connection is considered stale and replaced. Default 10s.
263        pub fn heartbeat_timeout(mut self, timeout: std::time::Duration) -> Self {
264            self.config.heartbeat_timeout = timeout;
265            self
266        }
267
268        /// Sets the reconnect backoff: the delay starts at `initial` and
269        /// doubles (with jitter) up to `max`. Defaults: 1s → 5 minutes.
270        /// The 5-minute default cap keeps retry traffic negligible across
271        /// extended outages such as OANDA's weekend maintenance windows.
272        pub fn backoff(mut self, initial: std::time::Duration, max: std::time::Duration) -> Self {
273            self.config.backoff_initial = initial;
274            self.config.backoff_max = max;
275            self
276        }
277
278        /// How long a connection must stay healthy before the backoff
279        /// resets to its initial delay (default 60s). Prevents
280        /// connect/die/connect churn from bypassing the cooldown.
281        pub fn backoff_reset_after(mut self, stable: std::time::Duration) -> Self {
282            self.config.backoff_reset_after = stable;
283            self
284        }
285
286        /// Limits the number of consecutive failed reconnect attempts
287        /// before the stream gives up with an error. Default: unlimited
288        /// (rides out arbitrarily long outages).
289        pub fn max_reconnect_attempts(mut self, attempts: u32) -> Self {
290            self.config.max_reconnect_attempts = Some(attempts);
291            self
292        }
293    };
294}
295
296pub(crate) use stream_config_setters;