use crate::{
AccountID, Client, Error, Result,
admission::StreamLease,
models::{ClientPrice, PricingHeartbeat, Transaction, TransactionHeartbeat},
};
use reqwest::Response;
use serde::Serialize;
use std::time::Duration;
const MAX_RECORD_BYTES: usize = 1024 * 1024;
const MAX_BACKLOG_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum PriceStreamEvent {
Price(Box<ClientPrice>),
Heartbeat(PricingHeartbeat),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum TransactionStreamEvent {
Transaction(Box<Transaction>),
Heartbeat(TransactionHeartbeat),
}
pub struct HttpStream<E> {
response: Option<Response>,
buffer: Vec<u8>,
start: usize,
decoder: fn(&[u8]) -> Result<E>,
ended: bool,
lease: Option<StreamLease>,
}
impl<E> HttpStream<E> {
fn finish(&mut self) {
self.ended = true;
self.response.take();
self.lease.take();
}
pub async fn next_event(&mut self) -> Option<Result<E>> {
if self.ended {
return None;
}
if self.buffer.len() - self.start > MAX_BACKLOG_BYTES {
self.finish();
return Some(Err(Error::ResponseTooLarge));
}
loop {
if let Some(offset) = self.buffer[self.start..]
.iter()
.position(|byte| *byte == b'\n')
{
let newline = self.start + offset;
let mut end = newline;
if end > self.start && self.buffer[end - 1] == b'\r' {
end -= 1;
}
if end - self.start > MAX_RECORD_BYTES {
self.finish();
return Some(Err(Error::ResponseTooLarge));
}
let line: Vec<u8> = self.buffer[self.start..end].to_vec();
self.start = newline + 1;
if self.start == self.buffer.len() {
self.buffer.clear();
self.start = 0;
} else if self.start > MAX_RECORD_BYTES {
self.buffer.drain(..self.start);
self.start = 0;
}
if line.is_empty() {
continue;
}
let result = (self.decoder)(&line);
if result.is_err() {
self.finish();
}
return Some(result);
}
if self.buffer.len() - self.start > MAX_RECORD_BYTES {
self.finish();
return Some(Err(Error::ResponseTooLarge));
}
let Some(response) = self.response.as_mut() else {
self.finish();
return None;
};
match tokio::time::timeout(Duration::from_secs(30), response.chunk()).await {
Err(_) => {
self.finish();
return Some(Err(Error::Transport(
"OANDA stream heartbeat timed out; continuity must be recovered".into(),
)));
}
Ok(Ok(Some(chunk))) => {
self.buffer.extend_from_slice(&chunk);
if self.buffer.len() - self.start > MAX_BACKLOG_BYTES {
self.finish();
return Some(Err(Error::ResponseTooLarge));
}
}
Ok(Ok(None)) => {
self.finish();
return Some(Err(Error::Transport(
"OANDA stream ended; continuity must be recovered".into(),
)));
}
Ok(Err(error)) => {
self.finish();
return Some(Err(Error::Transport(error.to_string())));
}
}
}
}
}
impl Client {
pub(crate) async fn open_http_stream<E, Q: Serialize>(
&self,
path: &str,
query: Option<&Q>,
decoder: fn(&[u8]) -> Result<E>,
) -> Result<HttpStream<E>> {
let lease = self.inner.admission.lease_stream()?;
let url = Self::build_url(&self.inner.stream, path, query)?;
let response = self
.inner
.stream_http
.get(url)
.bearer_auth(&self.inner.token)
.header(
"Accept-Datetime-Format",
self.inner.datetime_format.as_str(),
)
.send()
.await
.map_err(|e| Error::Transport(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = crate::client::read_bounded(response, self.inner.max_body).await?;
let reason = serde_json::from_slice::<serde_json::Value>(&body).ok();
let code = reason
.as_ref()
.and_then(|value| value.get("errorCode"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let message = reason
.as_ref()
.and_then(|value| value.get("errorMessage"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
return Err(Error::Provider {
status,
code,
message,
});
}
Ok(HttpStream {
response: Some(response),
buffer: Vec::new(),
start: 0,
decoder,
ended: false,
lease: Some(lease),
})
}
pub async fn stream_pricing(
&self,
account: &AccountID,
query: &crate::pricing::StreamPricingQuery,
) -> Result<HttpStream<PriceStreamEvent>> {
if query.instruments.is_empty() {
return Err(Error::InvalidInput(
"pricing instruments must not be empty".into(),
));
}
let path = format!(
"/v3/accounts/{}/pricing/stream",
crate::client::segment(account.as_str())
);
self.open_http_stream(&path, Some(query), decode_price)
.await
}
pub async fn stream_transactions(
&self,
account: &AccountID,
) -> Result<HttpStream<TransactionStreamEvent>> {
let path = format!(
"/v3/accounts/{}/transactions/stream",
crate::client::segment(account.as_str())
);
self.open_http_stream(&path, None::<&()>, decode_transaction)
.await
}
}
fn decode_price(line: &[u8]) -> Result<PriceStreamEvent> {
let value: serde_json::Value =
serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
match value.get("type").and_then(serde_json::Value::as_str) {
Some("PRICE") => serde_json::from_value(value)
.map(Box::new)
.map(PriceStreamEvent::Price)
.map_err(|e| Error::Decode(e.to_string())),
Some("HEARTBEAT") => serde_json::from_value(value)
.map(PriceStreamEvent::Heartbeat)
.map_err(|e| Error::Decode(e.to_string())),
_ => Err(Error::Decode("unknown OANDA pricing stream record".into())),
}
}
fn decode_transaction(line: &[u8]) -> Result<TransactionStreamEvent> {
let value: serde_json::Value =
serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
if value.get("type").and_then(serde_json::Value::as_str) == Some("HEARTBEAT") {
serde_json::from_value(value)
.map(TransactionStreamEvent::Heartbeat)
.map_err(|e| Error::Decode(e.to_string()))
} else {
serde_json::from_value(value)
.map(Box::new)
.map(TransactionStreamEvent::Transaction)
.map_err(|e| Error::Decode(e.to_string()))
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::Environment;
#[tokio::test]
async fn cancelled_stream_owner_releases_its_connection_slot() {
let client = Client::builder(Environment::Practice, "fixture").build();
assert!(client.is_ok());
let Ok(client) = client else { return };
if let Ok(mut slots) = client.inner.admission.inner.stream_slots.lock() {
slots.active = 1;
}
let lease = StreamLease {
owner: client.inner.admission.clone(),
};
let task = tokio::spawn(async move {
let _lease = lease;
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
task.abort();
let _ = task.await;
if let Ok(slots) = client.inner.admission.inner.stream_slots.lock() {
assert_eq!(slots.active, 0);
}
}
#[tokio::test]
async fn a_backlog_beyond_the_local_bound_ends_the_generation() {
let mut buffer = Vec::new();
for index in 0..200_000_u32 {
buffer.extend_from_slice(
format!(
"{{\"type\":\"HEARTBEAT\",\"lastTransactionID\":\"{index}\",\
\"time\":\"2024-01-02T03:04:05.000000000Z\"}}\r\n"
)
.as_bytes(),
);
}
assert!(
buffer.len() > MAX_BACKLOG_BYTES,
"fixture must exceed the cap"
);
let mut stream = HttpStream {
response: None,
buffer,
start: 0,
decoder: decode_transaction,
ended: false,
lease: None,
};
assert!(matches!(
stream.next_event().await,
Some(Err(Error::ResponseTooLarge))
));
assert!(stream.next_event().await.is_none());
}
#[tokio::test]
async fn a_cancelled_stream_releases_its_slot_even_after_a_poisoned_lock() {
let client = Client::builder(Environment::Practice, "fixture").build();
assert!(client.is_ok());
let Ok(client) = client else { return };
if let Ok(mut slots) = client.inner.admission.inner.stream_slots.lock() {
slots.active = 1;
}
let lease = StreamLease {
owner: client.inner.admission.clone(),
};
let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Ok(_held) = client.inner.admission.inner.stream_slots.lock() else {
return;
};
panic!("poison the lock");
}));
assert!(poisoned.is_err());
drop(lease);
let slots = client
.inner
.admission
.inner
.stream_slots
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
slots.active, 0,
"a released connection must not be reported as capacity in use"
);
}
}