Skip to main content

oanda_client/
stream.rs

1//! Bounded newline-delimited HTTP streams.
2
3use crate::{
4    AccountID, Client, Error, Result,
5    models::{ClientPrice, PricingHeartbeat, Transaction, TransactionHeartbeat},
6};
7use reqwest::Response;
8use serde::Serialize;
9use std::{
10    sync::Arc,
11    time::{Duration, Instant},
12};
13
14/// Local hostile-input bound on a single newline-delimited record.
15///
16/// This is the client's own memory bound, not a provider contract: OANDA
17/// documents no record size, and realistic pricing and transaction payloads are
18/// orders of magnitude smaller. A record over this limit ends the generation
19/// with an explicit gap. There is deliberately no bound on the transport chunk
20/// size or on the buffered backlog — HTTP chunk segmentation is not a provider
21/// record boundary, and a slow caller must never cause records to be evicted,
22/// because dropping records would invent a continuity gap that the provider did
23/// not create.
24const MAX_RECORD_BYTES: usize = 1024 * 1024;
25/// Local hostile-input bound on the total buffered-but-undelivered backlog.
26///
27/// Records already buffered are never evicted — a slow caller delays delivery
28/// rather than losing records — but a fill episode that parks more than this
29/// many bytes in the buffer, complete records included, ends the generation
30/// with an explicit gap. Without it a provider burst of complete records could
31/// occupy memory without bound, because no further reading — and therefore no
32/// per-record check — happens while undelivered records remain.
33const MAX_BACKLOG_BYTES: usize = 16 * 1024 * 1024;
34
35/// One event from OANDA's sampled pricing stream.
36#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum PriceStreamEvent {
39    /// A sampled account price.
40    Price(Box<ClientPrice>),
41    /// A stream heartbeat.
42    Heartbeat(PricingHeartbeat),
43}
44
45/// One event from OANDA's transaction stream.
46#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub enum TransactionStreamEvent {
49    /// A typed transaction variant.
50    Transaction(Box<Transaction>),
51    /// A stream heartbeat.
52    Heartbeat(TransactionHeartbeat),
53}
54
55/// An owned HTTP stream. Dropping it closes the connection and releases its slot.
56pub struct HttpStream<E> {
57    response: Option<Response>,
58    buffer: Vec<u8>,
59    start: usize,
60    decoder: fn(&[u8]) -> Result<E>,
61    ended: bool,
62    lease: Option<StreamLease>,
63}
64
65impl<E> HttpStream<E> {
66    fn finish(&mut self) {
67        self.ended = true;
68        self.response.take();
69        self.lease.take();
70    }
71    /// Read the next event. Any error ends this generation and creates a continuity gap.
72    pub async fn next_event(&mut self) -> Option<Result<E>> {
73        if self.ended {
74            return None;
75        }
76        // The backlog cap is enforced wherever the buffer could have grown:
77        // on entry, so no already-buffered burst larger than the cap is ever
78        // delivered from, and after each transport read. Records already
79        // delivered are never recalled; ending here is an explicit local gap.
80        if self.buffer.len() - self.start > MAX_BACKLOG_BYTES {
81            self.finish();
82            return Some(Err(Error::ResponseTooLarge));
83        }
84        loop {
85            // Records are consumed through a cursor rather than by draining the
86            // buffer per record: a large transport read must not turn record
87            // delivery into quadratic copying, and buffered records are never
88            // evicted, because a dropped record would invent a provider gap.
89            if let Some(offset) = self.buffer[self.start..]
90                .iter()
91                .position(|byte| *byte == b'\n')
92            {
93                let newline = self.start + offset;
94                let mut end = newline;
95                if end > self.start && self.buffer[end - 1] == b'\r' {
96                    end -= 1;
97                }
98                if end - self.start > MAX_RECORD_BYTES {
99                    self.finish();
100                    return Some(Err(Error::ResponseTooLarge));
101                }
102                let line: Vec<u8> = self.buffer[self.start..end].to_vec();
103                self.start = newline + 1;
104                if self.start == self.buffer.len() {
105                    self.buffer.clear();
106                    self.start = 0;
107                } else if self.start > MAX_RECORD_BYTES {
108                    self.buffer.drain(..self.start);
109                    self.start = 0;
110                }
111                if line.is_empty() {
112                    continue;
113                }
114                let result = (self.decoder)(&line);
115                if result.is_err() {
116                    self.finish();
117                }
118                return Some(result);
119            }
120            // No complete record is buffered. Bound the pending record, then
121            // read more transport data.
122            if self.buffer.len() - self.start > MAX_RECORD_BYTES {
123                self.finish();
124                return Some(Err(Error::ResponseTooLarge));
125            }
126            let Some(response) = self.response.as_mut() else {
127                self.finish();
128                return None;
129            };
130            match tokio::time::timeout(Duration::from_secs(30), response.chunk()).await {
131                Err(_) => {
132                    self.finish();
133                    return Some(Err(Error::Transport(
134                        "OANDA stream heartbeat timed out; continuity must be recovered".into(),
135                    )));
136                }
137                Ok(Ok(Some(chunk))) => {
138                    // An HTTP chunk is a transport read, not a record
139                    // boundary: one chunk may carry thousands of complete
140                    // records, so its size alone says nothing about provider
141                    // data. Records are bounded individually, before any
142                    // decode, and the buffered backlog is bounded in total:
143                    // refusing to buffer beyond the cap is a local, explicitly
144                    // reported limit, never eviction.
145                    self.buffer.extend_from_slice(&chunk);
146                    if self.buffer.len() - self.start > MAX_BACKLOG_BYTES {
147                        self.finish();
148                        return Some(Err(Error::ResponseTooLarge));
149                    }
150                }
151                Ok(Ok(None)) => {
152                    self.finish();
153                    return Some(Err(Error::Transport(
154                        "OANDA stream ended; continuity must be recovered".into(),
155                    )));
156                }
157                Ok(Err(error)) => {
158                    self.finish();
159                    return Some(Err(Error::Transport(error.to_string())));
160                }
161            }
162        }
163    }
164}
165
166struct StreamLease {
167    inner: Arc<crate::client::Inner>,
168}
169impl Drop for StreamLease {
170    fn drop(&mut self) {
171        // A dropped lease cannot report a failure, and skipping the release would
172        // report an exhausted connection slot as one the client may reuse. The
173        // slot counter holds plain integers, so a poisoned lock is still readable.
174        let mut slots = self
175            .inner
176            .stream_slots
177            .lock()
178            .unwrap_or_else(std::sync::PoisonError::into_inner);
179        slots.active = slots.active.saturating_sub(1);
180    }
181}
182
183impl Client {
184    pub(crate) async fn open_http_stream<E, Q: Serialize>(
185        &self,
186        path: &str,
187        query: Option<&Q>,
188        decoder: fn(&[u8]) -> Result<E>,
189    ) -> Result<HttpStream<E>> {
190        self.admit(false, false).await?;
191        {
192            let mut slots = self
193                .inner
194                .stream_slots
195                .lock()
196                .map_err(|_| Error::InvalidInput("stream slots lock poisoned".into()))?;
197            let now = Instant::now();
198            if slots.active >= 20
199                || slots
200                    .last_start
201                    .is_some_and(|start| now.duration_since(start) < Duration::from_millis(500))
202            {
203                return Err(Error::RateLimited);
204            }
205            slots.active += 1;
206            slots.last_start = Some(now);
207        }
208        let lease = StreamLease {
209            inner: self.inner.clone(),
210        };
211        let url = Self::build_url(&self.inner.stream, path, query)?;
212        let response = self
213            .inner
214            .stream_http
215            .get(url)
216            .bearer_auth(&self.inner.token)
217            .header(
218                "Accept-Datetime-Format",
219                self.inner.datetime_format.as_str(),
220            )
221            .send()
222            .await
223            .map_err(|e| Error::Transport(e.to_string()))?;
224        if !response.status().is_success() {
225            let status = response.status().as_u16();
226            let body = crate::client::read_bounded(response, self.inner.max_body).await?;
227            // A rejection body this client cannot read is not a rejection without a
228            // reason. Report the observed status and no reason; never a message or
229            // error code OANDA did not send. `Value::get` yields nothing for a
230            // document that is not an object, so no shape check is needed.
231            let reason = serde_json::from_slice::<serde_json::Value>(&body).ok();
232            let code = reason
233                .as_ref()
234                .and_then(|value| value.get("errorCode"))
235                .and_then(serde_json::Value::as_str)
236                .map(str::to_owned);
237            let message = reason
238                .as_ref()
239                .and_then(|value| value.get("errorMessage"))
240                .and_then(serde_json::Value::as_str)
241                .map(str::to_owned);
242            return Err(Error::Provider {
243                status,
244                code,
245                message,
246            });
247        }
248        Ok(HttpStream {
249            response: Some(response),
250            buffer: Vec::new(),
251            start: 0,
252            decoder,
253            ended: false,
254            lease: Some(lease),
255        })
256    }
257
258    /// Open the sampled account pricing stream.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error for an invalid query, exhausted stream capacity, transport failure, or
263    /// provider rejection.
264    pub async fn stream_pricing(
265        &self,
266        account: &AccountID,
267        query: &crate::pricing::StreamPricingQuery,
268    ) -> Result<HttpStream<PriceStreamEvent>> {
269        if query.instruments.is_empty() {
270            return Err(Error::InvalidInput(
271                "pricing instruments must not be empty".into(),
272            ));
273        }
274        let path = format!(
275            "/v3/accounts/{}/pricing/stream",
276            crate::client::segment(account.as_str())
277        );
278        self.open_http_stream(&path, Some(query), decode_price)
279            .await
280    }
281
282    /// Open the account transaction stream. It starts at connection time; use transaction history for recovery.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error for exhausted stream capacity, transport failure, or provider rejection.
287    pub async fn stream_transactions(
288        &self,
289        account: &AccountID,
290    ) -> Result<HttpStream<TransactionStreamEvent>> {
291        let path = format!(
292            "/v3/accounts/{}/transactions/stream",
293            crate::client::segment(account.as_str())
294        );
295        self.open_http_stream(&path, None::<&()>, decode_transaction)
296            .await
297    }
298}
299
300fn decode_price(line: &[u8]) -> Result<PriceStreamEvent> {
301    let value: serde_json::Value =
302        serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
303    match value.get("type").and_then(serde_json::Value::as_str) {
304        Some("PRICE") => serde_json::from_value(value)
305            .map(Box::new)
306            .map(PriceStreamEvent::Price)
307            .map_err(|e| Error::Decode(e.to_string())),
308        Some("HEARTBEAT") => serde_json::from_value(value)
309            .map(PriceStreamEvent::Heartbeat)
310            .map_err(|e| Error::Decode(e.to_string())),
311        _ => Err(Error::Decode("unknown OANDA pricing stream record".into())),
312    }
313}
314
315fn decode_transaction(line: &[u8]) -> Result<TransactionStreamEvent> {
316    let value: serde_json::Value =
317        serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
318    if value.get("type").and_then(serde_json::Value::as_str) == Some("HEARTBEAT") {
319        serde_json::from_value(value)
320            .map(TransactionStreamEvent::Heartbeat)
321            .map_err(|e| Error::Decode(e.to_string()))
322    } else {
323        serde_json::from_value(value)
324            .map(Box::new)
325            .map(TransactionStreamEvent::Transaction)
326            .map_err(|e| Error::Decode(e.to_string()))
327    }
328}
329
330#[cfg(test)]
331// Poisoning a lock requires a panic, and fixture failures are clearer as panics;
332// production paths return typed errors.
333#[allow(clippy::expect_used, clippy::panic)]
334mod tests {
335    use super::*;
336    use crate::Environment;
337
338    #[tokio::test]
339    async fn cancelled_stream_owner_releases_its_connection_slot() {
340        let client = Client::builder(Environment::Practice, "fixture").build();
341        assert!(client.is_ok());
342        let Ok(client) = client else { return };
343        if let Ok(mut slots) = client.inner.stream_slots.lock() {
344            slots.active = 1;
345        }
346        let lease = StreamLease {
347            inner: client.inner.clone(),
348        };
349        let task = tokio::spawn(async move {
350            let _lease = lease;
351            std::future::pending::<()>().await;
352        });
353        tokio::task::yield_now().await;
354        task.abort();
355        let _ = task.await;
356        if let Ok(slots) = client.inner.stream_slots.lock() {
357            assert_eq!(slots.active, 0);
358        }
359    }
360
361    #[tokio::test]
362    async fn a_backlog_beyond_the_local_bound_ends_the_generation() {
363        // A provider burst can park many complete records in the buffer at
364        // once, and while undelivered records remain no further reading — and
365        // therefore no per-record check — happens. Refusing to deliver from a
366        // backlog past the cap is a local, explicitly reported limit, never
367        // eviction of records already accepted.
368        let mut buffer = Vec::new();
369        for index in 0..200_000_u32 {
370            buffer.extend_from_slice(
371                format!(
372                    "{{\"type\":\"HEARTBEAT\",\"lastTransactionID\":\"{index}\",\
373                     \"time\":\"2024-01-02T03:04:05.000000000Z\"}}\r\n"
374                )
375                .as_bytes(),
376            );
377        }
378        assert!(
379            buffer.len() > MAX_BACKLOG_BYTES,
380            "fixture must exceed the cap"
381        );
382        let mut stream = HttpStream {
383            response: None,
384            buffer,
385            start: 0,
386            decoder: decode_transaction,
387            ended: false,
388            lease: None,
389        };
390        assert!(matches!(
391            stream.next_event().await,
392            Some(Err(Error::ResponseTooLarge))
393        ));
394        assert!(stream.next_event().await.is_none());
395    }
396
397    #[tokio::test]
398    async fn a_cancelled_stream_releases_its_slot_even_after_a_poisoned_lock() {
399        let client = Client::builder(Environment::Practice, "fixture").build();
400        assert!(client.is_ok());
401        let Ok(client) = client else { return };
402        if let Ok(mut slots) = client.inner.stream_slots.lock() {
403            slots.active = 1;
404        }
405        let lease = StreamLease {
406            inner: client.inner.clone(),
407        };
408        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
409            let Ok(_held) = client.inner.stream_slots.lock() else {
410                return;
411            };
412            panic!("poison the lock");
413        }));
414        assert!(poisoned.is_err());
415        drop(lease);
416        let slots = client
417            .inner
418            .stream_slots
419            .lock()
420            .unwrap_or_else(std::sync::PoisonError::into_inner);
421        assert_eq!(
422            slots.active, 0,
423            "a released connection must not be reported as capacity in use"
424        );
425    }
426}