Skip to main content

oanda_client/
stream.rs

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