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