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    validation::Validate,
11};
12use reqwest::Response;
13use serde::Serialize;
14use std::time::Duration;
15
16/// Local hostile-input bound on a single newline-delimited record.
17///
18/// This is the client's own memory bound, not a provider contract: OANDA
19/// documents no record size, and realistic pricing and transaction payloads are
20/// orders of magnitude smaller. A record over this limit ends the generation
21/// with an explicit gap. There is deliberately no bound on the transport chunk
22/// size: HTTP chunk segmentation is not a provider record boundary. The separate
23/// total backlog cap below ends an oversized generation explicitly; it never
24/// evicts records to make room for later ones.
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/// Local maximum silence before ending a stream generation.
36const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(30);
37
38/// One event from OANDA's sampled pricing stream.
39#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub enum PriceStreamEvent {
42    /// A sampled account price.
43    Price(Box<ClientPrice>),
44    /// A stream heartbeat.
45    Heartbeat(PricingHeartbeat),
46}
47
48/// One event from OANDA's transaction stream.
49#[derive(Debug, Clone, PartialEq)]
50#[non_exhaustive]
51pub enum TransactionStreamEvent {
52    /// A typed transaction variant.
53    Transaction(Box<Transaction>),
54    /// A stream heartbeat.
55    Heartbeat(TransactionHeartbeat),
56}
57
58struct Buffered<E> {
59    bytes: Vec<u8>,
60    start: usize,
61    decoder: fn(&[u8]) -> Result<E>,
62}
63
64impl<E> Buffered<E> {
65    fn remaining(&self) -> Result<usize> {
66        self.bytes
67            .len()
68            .checked_sub(self.start)
69            .ok_or(Error::Invariant("stream buffer cursor exceeds its length"))
70    }
71
72    fn next(&mut self) -> Option<Result<E>> {
73        let remaining = match self.remaining() {
74            Ok(value) => value,
75            Err(error) => return Some(Err(error)),
76        };
77        if remaining > MAX_BACKLOG_BYTES {
78            return Some(Err(Error::ResponseTooLarge));
79        }
80        loop {
81            let Some(offset) = self.bytes[self.start..]
82                .iter()
83                .position(|byte| *byte == b'\n')
84            else {
85                return (self.bytes.len() - self.start > MAX_RECORD_BYTES)
86                    .then_some(Err(Error::ResponseTooLarge));
87            };
88            let newline = self.start + offset;
89            let end = if newline > self.start && self.bytes[newline - 1] == b'\r' {
90                newline - 1
91            } else {
92                newline
93            };
94            if end - self.start > MAX_RECORD_BYTES {
95                return Some(Err(Error::ResponseTooLarge));
96            }
97            // Decode the borrowed slice before compaction; the decoder returns owned evidence.
98            let event = (end > self.start).then(|| (self.decoder)(&self.bytes[self.start..end]));
99            self.start = newline + 1;
100            if self.start == self.bytes.len() {
101                self.bytes.clear();
102                self.start = 0;
103            } else if self.start > MAX_RECORD_BYTES {
104                self.bytes.drain(..self.start);
105                self.start = 0;
106            }
107            if let Some(event) = event {
108                return Some(event);
109            }
110        }
111    }
112}
113
114/// A finite, owned prefix of an HTTP stream's already buffered records.
115///
116/// The HTTP response and admission lease are closed before this value is returned.
117/// Iteration is synchronous: it performs no network read and requires no runtime.
118/// It yields records in their original order, ending after the first error or
119/// after the prefix is exhausted. A nonempty unterminated tail is an explicit
120/// error. Dropping this value discards any undelivered prefix, so closure alone
121/// does not prove that the caller consumed it.
122#[must_use = "consume the finite prefix and handle errors before claiming it was drained"]
123pub struct BufferedEvents<E> {
124    buffered: Buffered<E>,
125    initial_error: Option<Error>,
126    finished: bool,
127}
128
129impl<E> std::fmt::Debug for BufferedEvents<E> {
130    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        formatter
132            .debug_struct("BufferedEvents")
133            .field(
134                "remaining_bytes",
135                &self.buffered.bytes.len().checked_sub(self.buffered.start),
136            )
137            .field("finished", &self.finished)
138            .finish_non_exhaustive()
139    }
140}
141
142impl<E> Iterator for BufferedEvents<E> {
143    type Item = Result<E>;
144
145    fn next(&mut self) -> Option<Self::Item> {
146        if self.finished {
147            return None;
148        }
149        if let Some(error) = self.initial_error.take() {
150            self.finished = true;
151            return Some(Err(error));
152        }
153        if let Some(event) = self.buffered.next() {
154            self.finished = event.is_err();
155            return Some(event);
156        }
157        self.finished = true;
158        match self.buffered.remaining() {
159            Ok(0) => None,
160            Ok(bytes) => Some(Err(Error::IncompleteStreamRecord { bytes })),
161            Err(error) => Some(Err(error)),
162        }
163    }
164}
165
166impl<E> std::iter::FusedIterator for BufferedEvents<E> {}
167
168/// An owned HTTP stream. Dropping it closes the connection and releases its slot.
169pub struct HttpStream<E> {
170    response: Option<Response>,
171    buffered: Buffered<E>,
172    ended: bool,
173    lease: Option<StreamLease>,
174}
175
176impl<E> HttpStream<E> {
177    fn finish(&mut self) {
178        self.ended = true;
179        self.response.take();
180        self.lease.take();
181    }
182
183    /// Closes network ingress and transfers exactly the already buffered prefix.
184    ///
185    /// The returned iterator retains the decoder and original ordering, with the
186    /// same record and backlog limits. It owns no response or admission lease.
187    /// No unread OS/socket/HTTP-body bytes are fetched; this is a local buffer
188    /// cut, not evidence of a complete venue sequence or transaction boundary.
189    /// Cancel a pending `next_event` borrow before consuming the stream here.
190    ///
191    /// The iterator yields `StreamAlreadyEnded` if this generation had already
192    /// ended, and `IncompleteStreamRecord` for an unterminated tail. Decode and
193    /// size errors also end the iterator. Exhaust it successfully before claiming
194    /// that the buffered prefix was delivered.
195    pub fn close(mut self) -> BufferedEvents<E> {
196        let initial_error = self.ended.then_some(Error::StreamAlreadyEnded);
197        self.finish();
198        BufferedEvents {
199            buffered: self.buffered,
200            initial_error,
201            finished: false,
202        }
203    }
204
205    /// Read the next event. Any error ends this generation and creates a continuity gap.
206    pub async fn next_event(&mut self) -> Option<Result<E>> {
207        if self.ended {
208            return None;
209        }
210        loop {
211            if let Some(result) = self.buffered.next() {
212                if result.is_err() {
213                    self.finish();
214                }
215                return Some(result);
216            }
217            let Some(response) = self.response.as_mut() else {
218                self.finish();
219                return None;
220            };
221            match tokio::time::timeout(HEARTBEAT_TIMEOUT, response.chunk()).await {
222                Err(_) => {
223                    self.finish();
224                    return Some(Err(Error::Transport(
225                        "OANDA stream heartbeat timed out; continuity must be recovered".into(),
226                    )));
227                }
228                Ok(Ok(Some(chunk))) => {
229                    self.buffered.bytes.extend_from_slice(&chunk);
230                    match self.buffered.remaining() {
231                        Ok(bytes) if bytes <= MAX_BACKLOG_BYTES => {}
232                        Ok(_) => {
233                            self.finish();
234                            return Some(Err(Error::ResponseTooLarge));
235                        }
236                        Err(error) => {
237                            self.finish();
238                            return Some(Err(error));
239                        }
240                    }
241                }
242                Ok(Ok(None)) => {
243                    self.finish();
244                    return Some(Err(Error::Transport(
245                        "OANDA stream ended; continuity must be recovered".into(),
246                    )));
247                }
248                Ok(Err(error)) => {
249                    self.finish();
250                    return Some(Err(Error::Transport(error.to_string())));
251                }
252            }
253        }
254    }
255}
256
257impl Client {
258    pub(crate) async fn open_http_stream<E, Q: Validate + Serialize>(
259        &self,
260        path: &str,
261        query: Option<&Q>,
262        decoder: fn(&[u8]) -> Result<E>,
263    ) -> Result<HttpStream<E>> {
264        let query = query
265            .map(|query| {
266                query.validate()?;
267                serde_json::to_value(query).map_err(|error| Error::InvalidInput(error.to_string()))
268            })
269            .transpose()?;
270        let url = Self::build_url(&self.inner.stream, path, query.as_ref())?;
271        let lease = self.inner.admission.lease_stream()?;
272        let response = self
273            .inner
274            .stream_http
275            .get(url)
276            .bearer_auth(self.inner.token.as_str())
277            .header(
278                "Accept-Datetime-Format",
279                self.inner.datetime_format.as_str(),
280            )
281            .send()
282            .await
283            .map_err(|e| Error::Transport(e.to_string()))?;
284        if !response.status().is_success() {
285            let status = response.status().as_u16();
286            let body = crate::client::read_bounded(response, self.inner.max_body).await?;
287            // A rejection body this client cannot read is not a rejection without a
288            // reason. Report the observed status and no reason; never a message or
289            // error code OANDA did not send. `Value::get` yields nothing for a
290            // document that is not an object, so no shape check is needed.
291            let reason = serde_json::from_slice::<serde_json::Value>(&body).ok();
292            let code = reason
293                .as_ref()
294                .and_then(|value| value.get("errorCode"))
295                .and_then(serde_json::Value::as_str)
296                .map(str::to_owned);
297            let message = reason
298                .as_ref()
299                .and_then(|value| value.get("errorMessage"))
300                .and_then(serde_json::Value::as_str)
301                .map(str::to_owned);
302            return Err(Error::Provider {
303                status,
304                code,
305                message,
306            });
307        }
308        Ok(HttpStream {
309            response: Some(response),
310            buffered: Buffered {
311                bytes: Vec::new(),
312                start: 0,
313                decoder,
314            },
315            ended: false,
316            lease: Some(lease),
317        })
318    }
319
320    /// Open the sampled account pricing stream.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error for an invalid query, exhausted stream capacity, transport failure, or
325    /// provider rejection.
326    pub async fn stream_pricing(
327        &self,
328        account: &AccountID,
329        query: &crate::pricing::StreamPricingQuery,
330    ) -> Result<HttpStream<PriceStreamEvent>> {
331        let path = format!(
332            "/v3/accounts/{}/pricing/stream",
333            crate::client::segment(account.as_str())
334        );
335        self.open_http_stream(&path, Some(query), decode_price)
336            .await
337    }
338
339    /// Open the account transaction stream. It starts at connection time; use transaction history for recovery.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error for exhausted stream capacity, transport failure, or provider rejection.
344    pub async fn stream_transactions(
345        &self,
346        account: &AccountID,
347    ) -> Result<HttpStream<TransactionStreamEvent>> {
348        let path = format!(
349            "/v3/accounts/{}/transactions/stream",
350            crate::client::segment(account.as_str())
351        );
352        self.open_http_stream(&path, None::<&()>, decode_transaction)
353            .await
354    }
355}
356
357fn decode_price(line: &[u8]) -> Result<PriceStreamEvent> {
358    let value: serde_json::Value =
359        serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
360    match value.get("type").and_then(serde_json::Value::as_str) {
361        Some("PRICE") => serde_json::from_value(value)
362            .map(Box::new)
363            .map(PriceStreamEvent::Price)
364            .map_err(|e| Error::Decode(e.to_string())),
365        Some("HEARTBEAT") => serde_json::from_value(value)
366            .map(PriceStreamEvent::Heartbeat)
367            .map_err(|e| Error::Decode(e.to_string())),
368        _ => Err(Error::Decode("unknown OANDA pricing stream record".into())),
369    }
370}
371
372fn decode_transaction(line: &[u8]) -> Result<TransactionStreamEvent> {
373    let value: serde_json::Value =
374        serde_json::from_slice(line).map_err(|e| Error::Decode(e.to_string()))?;
375    if value.get("type").and_then(serde_json::Value::as_str) == Some("HEARTBEAT") {
376        serde_json::from_value(value)
377            .map(TransactionStreamEvent::Heartbeat)
378            .map_err(|e| Error::Decode(e.to_string()))
379    } else {
380        serde_json::from_value(value)
381            .map(Box::new)
382            .map(TransactionStreamEvent::Transaction)
383            .map_err(|e| Error::Decode(e.to_string()))
384    }
385}
386
387#[cfg(test)]
388// Poisoning a lock requires a panic, and fixture failures are clearer as panics;
389// production paths return typed errors.
390#[allow(clippy::expect_used, clippy::panic)]
391mod tests {
392    use super::*;
393    use crate::Environment;
394
395    fn from_buffer<E>(bytes: Vec<u8>, decoder: fn(&[u8]) -> Result<E>) -> HttpStream<E> {
396        HttpStream {
397            response: None,
398            buffered: Buffered {
399                bytes,
400                start: 0,
401                decoder,
402            },
403            ended: false,
404            lease: None,
405        }
406    }
407
408    fn heartbeat(id: u32) -> String {
409        format!(
410            "{{\"type\":\"HEARTBEAT\",\"lastTransactionID\":\"{id}\",\"time\":\"2024-01-02T03:04:05.000000001Z\"}}\r\n"
411        )
412    }
413
414    fn transaction_id(event: Result<TransactionStreamEvent>) -> String {
415        match event.expect("valid synthetic heartbeat") {
416            TransactionStreamEvent::Heartbeat(value) => {
417                value.last_transaction_id.expect("fixture id").to_string()
418            }
419            TransactionStreamEvent::Transaction(_) => panic!("fixture contains no transaction"),
420        }
421    }
422
423    #[tokio::test]
424    async fn closing_preserves_the_exact_remaining_complete_prefix_and_original_decoder() {
425        let bytes = format!("\r\n{}\n{}{}", heartbeat(10), heartbeat(11), heartbeat(12));
426        let mut stream = from_buffer(bytes.into_bytes(), decode_transaction);
427        assert_eq!(
428            transaction_id(stream.next_event().await.expect("first record")),
429            "10"
430        );
431        let mut buffered = stream.close();
432        assert_eq!(
433            transaction_id(buffered.next().expect("second record")),
434            "11"
435        );
436        assert_eq!(transaction_id(buffered.next().expect("third record")), "12");
437        assert!(buffered.next().is_none());
438        assert!(buffered.next().is_none());
439        let price = br#"{"type":"PRICE","instrument":"EUR_USD","closeoutBid":"1.2300","bids":[{"price":"1.229900","liquidity":17}]}"#;
440        let mut bytes = price.to_vec();
441        bytes.extend_from_slice(b"\r\n");
442        let mut buffered = from_buffer(bytes, decode_price).close();
443        match buffered
444            .next()
445            .expect("price")
446            .expect("typed pricing decode")
447        {
448            PriceStreamEvent::Price(value) => {
449                let bid = value.closeout_bid.expect("fixture bid");
450                assert_eq!(bid.to_string(), "1.2300");
451                assert_eq!(bid.scale(), 4);
452                assert_eq!(
453                    value.bids.expect("fixture buckets")[0]
454                        .price
455                        .expect("bucket price")
456                        .to_string(),
457                    "1.229900"
458                );
459            }
460            PriceStreamEvent::Heartbeat(_) => panic!("fixture is a price"),
461        }
462        assert!(buffered.next().is_none());
463    }
464
465    #[tokio::test]
466    async fn a_large_buffered_burst_drains_in_order_without_a_record_count_limit() {
467        let mut bytes = Vec::new();
468        for id in 0..30_000 {
469            bytes.extend_from_slice(heartbeat(id).as_bytes());
470        }
471        assert!(bytes.len() > MAX_RECORD_BYTES && bytes.len() < MAX_BACKLOG_BYTES);
472        let server = crate::test_support::held_stream(&bytes).await;
473        let client = crate::test_support::fixture_client(server.url.clone());
474        let account = AccountID::new("101-001-1-001").expect("fixture account");
475        let mut stream = client
476            .stream_transactions(&account)
477            .await
478            .expect("real stream");
479        tokio::time::timeout(Duration::from_secs(5), preload(&mut stream, bytes.len()))
480            .await
481            .expect("finite coalesced HTTP prefix");
482        let mut buffered = stream.close();
483        for id in 0..30_000 {
484            assert_eq!(
485                transaction_id(buffered.next().expect("retained complete record")),
486                id.to_string()
487            );
488        }
489        assert!(buffered.next().is_none());
490        peer_closed(server).await;
491    }
492
493    #[test]
494    fn a_partial_tail_is_explicit_after_the_complete_prefix_and_then_fused() {
495        for tail in [b"{\"type\":".as_slice(), b"\r", b" "] {
496            let mut bytes = heartbeat(21).into_bytes();
497            bytes.extend_from_slice(tail);
498            let mut buffered = from_buffer(bytes, decode_transaction).close();
499            assert_eq!(
500                transaction_id(buffered.next().expect("complete record")),
501                "21"
502            );
503            assert!(
504                matches!(buffered.next(), Some(Err(Error::IncompleteStreamRecord { bytes })) if bytes == tail.len())
505            );
506            assert!(buffered.next().is_none());
507            assert!(buffered.next().is_none());
508        }
509        for empty in [b"".as_slice(), b"\n\r\n"] {
510            assert!(
511                from_buffer(empty.to_vec(), decode_transaction)
512                    .close()
513                    .next()
514                    .is_none()
515            );
516        }
517    }
518
519    #[test]
520    fn malformed_and_oversized_prefixes_end_explicitly_without_skipping_to_later_records() {
521        let mut malformed = b"not-json\n".to_vec();
522        malformed.extend_from_slice(heartbeat(31).as_bytes());
523        let mut buffered = from_buffer(malformed, decode_transaction).close();
524        assert!(matches!(buffered.next(), Some(Err(Error::Decode(_)))));
525        assert!(buffered.next().is_none());
526        for mut oversized in [
527            vec![b'x'; MAX_RECORD_BYTES + 1],
528            vec![b'x'; MAX_BACKLOG_BYTES + 1],
529        ] {
530            let mut partial = from_buffer(oversized.clone(), decode_transaction).close();
531            assert!(matches!(partial.next(), Some(Err(Error::ResponseTooLarge))));
532            assert!(partial.next().is_none());
533            oversized.extend_from_slice(b"\n");
534            oversized.extend_from_slice(heartbeat(32).as_bytes());
535            let mut complete = from_buffer(oversized, decode_transaction).close();
536            assert!(matches!(
537                complete.next(),
538                Some(Err(Error::ResponseTooLarge))
539            ));
540            assert!(complete.next().is_none());
541        }
542    }
543
544    #[tokio::test]
545    async fn an_already_ended_generation_cannot_report_a_clean_buffered_cut() {
546        let mut stream = from_buffer(b"not-json\n".to_vec(), decode_transaction);
547        assert!(matches!(
548            stream.next_event().await,
549            Some(Err(Error::Decode(_)))
550        ));
551        let mut buffered = stream.close();
552        assert!(matches!(
553            buffered.next(),
554            Some(Err(Error::StreamAlreadyEnded))
555        ));
556        assert!(buffered.next().is_none());
557    }
558
559    #[test]
560    fn buffered_debug_keeps_raw_data_private_and_invalid_cursors_explicit() {
561        let mut stream = from_buffer(b"sensitive-fixture-body\n".to_vec(), decode_transaction);
562        assert!(!format!("{:?}", stream.close()).contains("sensitive-fixture-body"));
563        stream = from_buffer(Vec::new(), decode_transaction);
564        stream.buffered.start = 1;
565        let mut buffered = stream.close();
566        assert!(matches!(buffered.next(), Some(Err(Error::Invariant(_)))));
567        assert!(buffered.next().is_none());
568    }
569
570    // Prepare an exact finite real HTTP body prefix without assuming TCP/chunk
571    // segmentation. The cut and iterator run only after these bytes are owned.
572    async fn preload(stream: &mut HttpStream<TransactionStreamEvent>, bytes: usize) {
573        while stream.buffered.remaining().expect("valid buffer cursor") < bytes {
574            let chunk = stream
575                .response
576                .as_mut()
577                .expect("open response")
578                .chunk()
579                .await
580                .expect("real HTTP chunk")
581                .expect("held body remains open");
582            stream.buffered.bytes.extend_from_slice(&chunk);
583        }
584        assert_eq!(stream.buffered.remaining().expect("buffer length"), bytes);
585    }
586
587    async fn peer_closed(server: crate::test_support::HeldStream) {
588        tokio::time::timeout(Duration::from_secs(5), server.closed)
589            .await
590            .expect("owned HTTP peer did not close")
591            .expect("loopback server task");
592    }
593
594    #[tokio::test]
595    async fn cancelling_a_real_http_read_then_closing_retains_partial_bytes_and_releases_the_peer()
596    {
597        use futures_util::FutureExt;
598        for prefix in [b"".as_slice(), b"{\"type\":"] {
599            let server = crate::test_support::held_stream(prefix).await;
600            let client = crate::test_support::fixture_client(server.url.clone());
601            let account = AccountID::new("101-001-1-001").expect("fixture account");
602            let mut stream = client
603                .stream_transactions(&account)
604                .await
605                .expect("real stream");
606            tokio::time::timeout(Duration::from_secs(5), preload(&mut stream, prefix.len()))
607                .await
608                .expect("finite HTTP prefix");
609            let mut reading = Box::pin(stream.next_event());
610            assert!(reading.as_mut().now_or_never().is_none());
611            drop(reading);
612            let mut buffered = stream.close();
613            if prefix.is_empty() {
614                assert!(buffered.next().is_none());
615            } else {
616                assert!(
617                    matches!(buffered.next(), Some(Err(Error::IncompleteStreamRecord { bytes })) if bytes == prefix.len())
618                );
619            }
620            assert!(buffered.next().is_none());
621            peer_closed(server).await;
622        }
623    }
624
625    #[tokio::test]
626    async fn real_http_close_releases_capacity_before_drain_and_preserves_sibling_stream_and_rest()
627    {
628        let owner = crate::Admission::with_exit_reserve(0).expect("admission owner");
629        let mut leases = Vec::new();
630        for _ in 0..18 {
631            leases.push(owner.lease_stream().expect("occupied sibling lease"));
632        }
633        let prefix = format!("{}{}", heartbeat(41), heartbeat(42));
634        let first = crate::test_support::held_stream(prefix.as_bytes()).await;
635        let sibling = crate::test_support::held_stream(heartbeat(43).as_bytes()).await;
636        let (rest, rest_server) = crate::test_support::server(crate::test_support::http(
637            "200 OK",
638            "application/json",
639            r#"{"accounts":[]}"#,
640        ))
641        .await;
642        let client = Client::builder(Environment::Practice, "fixture-secret")
643            .endpoints(rest.clone(), first.url.clone())
644            .admission(owner.clone())
645            .build()
646            .expect("client");
647        let other = Client::builder(Environment::Practice, "another-fixture-secret")
648            .endpoints(rest, sibling.url.clone())
649            .admission(owner.clone())
650            .build()
651            .expect("sibling client");
652        let account = AccountID::new("101-001-1-001").expect("fixture account");
653        let mut stream = client
654            .stream_transactions(&account)
655            .await
656            .expect("first real stream");
657        let mut sibling_stream = other
658            .stream_transactions(&account)
659            .await
660            .expect("sibling real stream");
661        assert!(matches!(owner.lease_stream(), Err(Error::RateLimited)));
662        assert_eq!(
663            transaction_id(stream.next_event().await.expect("first record")),
664            "41"
665        );
666        tokio::time::timeout(
667            Duration::from_secs(5),
668            preload(&mut stream, heartbeat(42).len()),
669        )
670        .await
671        .expect("remaining finite prefix");
672        let mut buffered = stream.close();
673        let reused = owner
674            .lease_stream()
675            .expect("closed stream slot is reusable before iterator consumption");
676        peer_closed(first).await;
677        assert_eq!(
678            transaction_id(
679                sibling_stream
680                    .next_event()
681                    .await
682                    .expect("sibling still live")
683            ),
684            "43"
685        );
686        assert!(
687            client
688                .list_accounts()
689                .await
690                .expect("REST remains available")
691                .body
692                .accounts
693                .expect("explicit account list")
694                .is_empty()
695        );
696        rest_server.await.expect("REST server");
697        assert_eq!(
698            transaction_id(buffered.next().expect("undelivered record")),
699            "42"
700        );
701        assert!(buffered.next().is_none());
702        drop(reused);
703        drop(leases);
704        drop(sibling_stream.close());
705        peer_closed(sibling).await;
706    }
707
708    #[tokio::test]
709    async fn real_http_buffered_malformed_oversized_and_unterminated_records_are_not_clean_drains()
710    {
711        let mut malformed = b"not-json\n".to_vec();
712        malformed.extend_from_slice(heartbeat(51).as_bytes());
713        let mut oversized = vec![b'x'; MAX_RECORD_BYTES + 1];
714        oversized.push(b'\n');
715        for (index, prefix) in [malformed, oversized, b"{\"type\":".to_vec()]
716            .into_iter()
717            .enumerate()
718        {
719            let server = crate::test_support::held_stream(&prefix).await;
720            let client = crate::test_support::fixture_client(server.url.clone());
721            let account = AccountID::new("101-001-1-001").expect("fixture account");
722            let mut stream = client
723                .stream_transactions(&account)
724                .await
725                .expect("real stream");
726            tokio::time::timeout(Duration::from_secs(5), preload(&mut stream, prefix.len()))
727                .await
728                .expect("finite HTTP prefix");
729            let mut buffered = stream.close();
730            match (index, buffered.next()) {
731                (0, Some(Err(Error::Decode(_)))) | (1, Some(Err(Error::ResponseTooLarge))) => {}
732                (2, Some(Err(Error::IncompleteStreamRecord { bytes }))) => {
733                    assert_eq!(bytes, prefix.len());
734                }
735                (_, observed) => panic!("buffered error was lost or changed: {observed:?}"),
736            }
737            assert!(buffered.next().is_none());
738            peer_closed(server).await;
739        }
740    }
741
742    #[tokio::test]
743    async fn an_errored_real_http_generation_cannot_be_closed_as_a_clean_prefix() {
744        let (url, server) = crate::test_support::server(crate::test_support::http(
745            "200 OK",
746            "application/octet-stream",
747            "not-json\n",
748        ))
749        .await;
750        let client = crate::test_support::fixture_client(url);
751        let account = AccountID::new("101-001-1-001").expect("fixture account");
752        let mut stream = client
753            .stream_transactions(&account)
754            .await
755            .expect("real stream");
756        assert!(matches!(
757            stream.next_event().await,
758            Some(Err(Error::Decode(_)))
759        ));
760        let mut buffered = stream.close();
761        assert!(matches!(
762            buffered.next(),
763            Some(Err(Error::StreamAlreadyEnded))
764        ));
765        assert!(buffered.next().is_none());
766        server.await.expect("real HTTP server");
767    }
768
769    #[tokio::test]
770    async fn cancelled_stream_owner_releases_its_connection_slot() {
771        let client = Client::builder(Environment::Practice, "fixture").build();
772        assert!(client.is_ok());
773        let Ok(client) = client else { return };
774        if let Ok(mut slots) = client.inner.admission.inner.stream_slots.lock() {
775            slots.active = 1;
776        }
777        let lease = StreamLease {
778            owner: client.inner.admission.clone(),
779        };
780        let task = tokio::spawn(async move {
781            let _lease = lease;
782            std::future::pending::<()>().await;
783        });
784        tokio::task::yield_now().await;
785        task.abort();
786        let _ = task.await;
787        if let Ok(slots) = client.inner.admission.inner.stream_slots.lock() {
788            assert_eq!(slots.active, 0);
789        }
790    }
791
792    #[tokio::test]
793    async fn a_backlog_beyond_the_local_bound_ends_the_generation() {
794        // A provider burst can park many complete records in the buffer at
795        // once, and while undelivered records remain no further reading — and
796        // therefore no per-record check — happens. Refusing to deliver from a
797        // backlog past the cap is a local, explicitly reported limit, never
798        // eviction of records already accepted.
799        let mut buffer = Vec::new();
800        for index in 0..200_000_u32 {
801            buffer.extend_from_slice(
802                format!(
803                    "{{\"type\":\"HEARTBEAT\",\"lastTransactionID\":\"{index}\",\
804                     \"time\":\"2024-01-02T03:04:05.000000000Z\"}}\r\n"
805                )
806                .as_bytes(),
807            );
808        }
809        assert!(
810            buffer.len() > MAX_BACKLOG_BYTES,
811            "fixture must exceed the cap"
812        );
813        let mut stream = HttpStream {
814            response: None,
815            buffered: Buffered {
816                bytes: buffer,
817                start: 0,
818                decoder: decode_transaction,
819            },
820            ended: false,
821            lease: None,
822        };
823        assert!(matches!(
824            stream.next_event().await,
825            Some(Err(Error::ResponseTooLarge))
826        ));
827        assert!(stream.next_event().await.is_none());
828    }
829
830    #[tokio::test]
831    async fn a_cancelled_stream_releases_its_slot_even_after_a_poisoned_lock() {
832        let client = Client::builder(Environment::Practice, "fixture").build();
833        assert!(client.is_ok());
834        let Ok(client) = client else { return };
835        if let Ok(mut slots) = client.inner.admission.inner.stream_slots.lock() {
836            slots.active = 1;
837        }
838        let lease = StreamLease {
839            owner: client.inner.admission.clone(),
840        };
841        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
842            let Ok(_held) = client.inner.admission.inner.stream_slots.lock() else {
843                return;
844            };
845            panic!("poison the lock");
846        }));
847        assert!(poisoned.is_err());
848        drop(lease);
849        let slots = client
850            .inner
851            .admission
852            .inner
853            .stream_slots
854            .lock()
855            .unwrap_or_else(std::sync::PoisonError::into_inner);
856        assert_eq!(
857            slots.active, 0,
858            "a released connection must not be reported as capacity in use"
859        );
860    }
861}