1use 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
14const MAX_RECORD_BYTES: usize = 1024 * 1024;
25const MAX_BACKLOG_BYTES: usize = 16 * 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum PriceStreamEvent {
39 Price(Box<ClientPrice>),
41 Heartbeat(PricingHeartbeat),
43}
44
45#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub enum TransactionStreamEvent {
49 Transaction(Box<Transaction>),
51 Heartbeat(TransactionHeartbeat),
53}
54
55pub 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 pub async fn next_event(&mut self) -> Option<Result<E>> {
73 if self.ended {
74 return None;
75 }
76 if self.buffer.len() - self.start > MAX_BACKLOG_BYTES {
81 self.finish();
82 return Some(Err(Error::ResponseTooLarge));
83 }
84 loop {
85 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 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 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 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 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 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 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#[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 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}